diff --git a/.eslintrc.json b/.eslintrc.json
index 8844c39a..c1872a8f 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -15,11 +15,11 @@
}
],
"rules": {
- "sort-keys": "error",
- "sort-imports": "error",
+ "sort-keys": 2,
+ "sort-imports": 2,
"@typescript-eslint/no-var-requires": "warn",
"@typescript-eslint/no-unused-vars": "error",
- "@typescript-eslint/no-explicit-any": "error",
+ "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/ban-ts-comment": "warn"
}
}
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 0064d088..f866ac5a 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -1,9 +1,10 @@
name: Playwright Tests
on:
- # push:
- # branches: []
- pull_request:
- branches: [main, deploy/staging]
+# push:
+# branches: []
+# pull_request:
+# branches: [main, deploy/staging]
+ workflow_dispatch:
jobs:
test:
timeout-minutes: 10
diff --git a/components/Chat/Chat.test.tsx b/components/Chat/Chat.test.tsx
new file mode 100644
index 00000000..08182042
--- /dev/null
+++ b/components/Chat/Chat.test.tsx
@@ -0,0 +1,174 @@
+import { render, screen } from "@/test-utils";
+
+import Chat from "@/components/Chat/Chat";
+import { SearchProvider } from "@/context/search-context";
+import mockRouter from "next-router-mock";
+import useChatSocket from "@/hooks/useChatSocket";
+
+const mockSendMessage = jest.fn();
+
+jest.mock("@/context/search-context", () => {
+ const actual = jest.requireActual("@/context/search-context");
+
+ return {
+ __esModule: true,
+ ...actual,
+ useSearchState: () => ({
+ searchDispatch: jest.fn(),
+ searchState: {
+ activeTab: "stream",
+ aggregations: {},
+ chat: {
+ answer: "",
+ documents: [],
+ end: "stop",
+ question: "",
+ },
+ searchFixed: false,
+ },
+ }),
+ };
+});
+
+jest.mock("@/components/Chat/Response/Response", () => {
+ return function MockChatResponse(props: any) {
+ return (
+
+ Mock Chat Response
+
+ );
+ };
+});
+
+// Mock the useChatSocket hook and provide a default mock
+// implementation which can be overridden in individual tests
+jest.mock("@/hooks/useChatSocket");
+(useChatSocket as jest.Mock).mockImplementation(() => ({
+ authToken: "fake-token-1",
+ isConnected: false,
+ message: { answer: "fake-answer-1", end: "stop" },
+ sendMessage: mockSendMessage,
+}));
+
+describe("Chat component", () => {
+ it("renders default placeholder text when no search term is present", () => {
+ render();
+
+ const wrapper = screen.getByText(
+ "What can I help you find? Try searching for",
+ {
+ exact: false,
+ },
+ );
+ expect(wrapper).toBeInTheDocument();
+ });
+
+ it("renders the chat response component when search term is present", () => {
+ mockRouter.setCurrentUrl("/search?q=tell+me+about+boats");
+
+ render(
+
+
+ ,
+ );
+
+ const el = screen.getByTestId("mock-chat-response");
+ expect(el).toBeInTheDocument();
+
+ const dataProps = el.getAttribute("data-props");
+ expect(JSON.parse(dataProps!)).toEqual({
+ isStreamingComplete: false,
+ searchTerm: "tell me about boats",
+ sourceDocuments: [],
+ streamedAnswer: "",
+ });
+ });
+
+ it("sends a websocket message when the search term changes", () => {
+ const mockMessage = jest.fn();
+
+ (useChatSocket as jest.Mock).mockImplementation(() => ({
+ authToken: "fake-token",
+ isConnected: true,
+ message: { answer: "fake-answer-1" },
+ sendMessage: mockMessage,
+ }));
+
+ mockRouter.setCurrentUrl("/search?q=boats");
+
+ render(
+
+
+ ,
+ );
+
+ expect(mockMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ auth: "fake-token",
+ message: "chat",
+ question: "boats",
+ }),
+ );
+ });
+
+ it("doesn't send a websocket message if the search term is empty", () => {
+ mockRouter.setCurrentUrl("/search");
+ render(
+
+
+ ,
+ );
+
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ });
+
+ it("displays an error message when the response hits the LLM token limit", () => {
+ (useChatSocket as jest.Mock).mockImplementation(() => ({
+ authToken: "fake",
+ isConnected: true,
+ message: {
+ end: {
+ reason: "length",
+ ref: "fake",
+ },
+ },
+ sendMessage: mockSendMessage,
+ }));
+
+ mockRouter.setCurrentUrl("/search?q=boats");
+
+ render(
+
+
+ ,
+ );
+
+ const error = screen.getByText("The response has hit the LLM token limit.");
+ expect(error).toBeInTheDocument();
+ });
+
+ it("displays an error message when the response times out", () => {
+ (useChatSocket as jest.Mock).mockImplementation(() => ({
+ authToken: "fake",
+ isConnected: true,
+ message: {
+ end: {
+ reason: "timeout",
+ ref: "fake",
+ },
+ },
+ sendMessage: mockSendMessage,
+ }));
+
+ mockRouter.setCurrentUrl("/search?q=boats");
+
+ render(
+
+
+ ,
+ );
+
+ const error = screen.getByText("The response has timed out.");
+ expect(error).toBeInTheDocument();
+ });
+});
diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx
new file mode 100644
index 00000000..9ff8eeb2
--- /dev/null
+++ b/components/Chat/Chat.tsx
@@ -0,0 +1,169 @@
+import { AI_DISCLAIMER, AI_SEARCH_UNSUBMITTED } from "@/lib/constants/common";
+import React, { useEffect, useState } from "react";
+import {
+ StyledResponseActions,
+ StyledResponseDisclaimer,
+ StyledUnsubmitted,
+} from "@/components/Chat/Response/Response.styled";
+import { defaultState, useSearchState } from "@/context/search-context";
+
+import Announcement from "@/components/Shared/Announcement";
+import { Button } from "@nulib/design-system";
+import ChatFeedback from "@/components/Chat/Feedback/Feedback";
+import ChatResponse from "@/components/Chat/Response/Response";
+import Container from "@/components/Shared/Container";
+import { Work } from "@nulib/dcapi-types";
+import { pluralize } from "@/lib/utils/count-helpers";
+import { prepareQuestion } from "@/lib/chat-helpers";
+import useChatSocket from "@/hooks/useChatSocket";
+import useQueryParams from "@/hooks/useQueryParams";
+
+const Chat = ({
+ totalResults,
+ viewResultsCallback,
+}: {
+ totalResults?: number;
+ viewResultsCallback: () => void;
+}) => {
+ const { searchTerm = "" } = useQueryParams();
+ const { authToken, isConnected, message, sendMessage } = useChatSocket();
+
+ const [streamingError, setStreamingError] = useState("");
+
+ /**
+ * get the`chat` state and dispatch function from the search context
+ * for persisting the chat state when search screen tabs are switched
+ */
+ const {
+ searchState: { chat },
+ searchDispatch,
+ } = useSearchState();
+ const { question, answer, documents } = chat;
+
+ const [sourceDocuments, setSourceDocuments] = useState([]);
+ const [streamedAnswer, setStreamedAnswer] = useState("");
+
+ const isStreamingComplete = !!question && searchTerm === question;
+
+ useEffect(() => {
+ if (!isStreamingComplete && isConnected && authToken && searchTerm) {
+ resetChat();
+ const preparedQuestion = prepareQuestion(searchTerm, authToken);
+ sendMessage(preparedQuestion);
+ }
+ }, [authToken, isStreamingComplete, isConnected, searchTerm, sendMessage]);
+
+ useEffect(() => {
+ if (!message) return;
+
+ const updateSourceDocuments = () => {
+ setSourceDocuments(message.source_documents!);
+ };
+
+ const updateStreamedAnswer = () => {
+ setStreamedAnswer((prev) => prev + message.token);
+ };
+
+ const updateChat = () => {
+ searchDispatch({
+ chat: {
+ answer: message.answer || "",
+ documents: sourceDocuments,
+ question: searchTerm || "",
+ ref: message.ref,
+ },
+ type: "updateChat",
+ });
+ };
+
+ if (message.source_documents) {
+ updateSourceDocuments();
+ return;
+ }
+
+ if (message.token) {
+ updateStreamedAnswer();
+ return;
+ }
+
+ if (message.end) {
+ switch (message.end.reason) {
+ case "length":
+ setStreamingError("The response has hit the LLM token limit.");
+ break;
+ case "timeout":
+ setStreamingError("The response has timed out.");
+ break;
+ case "eos_token":
+ setStreamingError("This should never happen.");
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (message.answer) {
+ updateChat();
+ }
+ }, [message]);
+
+ function handleNewQuestion() {
+ const input = document.getElementById("dc-search") as HTMLInputElement;
+ if (input) {
+ input.focus();
+ input.value = "";
+ }
+ }
+
+ function resetChat() {
+ searchDispatch({
+ chat: defaultState.chat,
+ type: "updateChat",
+ });
+ setStreamedAnswer("");
+ setSourceDocuments([]);
+ }
+
+ if (!searchTerm)
+ return (
+
+ {AI_SEARCH_UNSUBMITTED}
+
+ );
+
+ return (
+ <>
+
+ {streamingError && (
+
+
+ {streamingError}
+
+
+ )}
+ {isStreamingComplete && (
+ <>
+
+
+
+
+
+ {AI_DISCLAIMER}
+
+
+ >
+ )}
+ >
+ );
+};
+
+export default React.memo(Chat);
diff --git a/components/Chat/Feedback/Feedback.tsx b/components/Chat/Feedback/Feedback.tsx
new file mode 100644
index 00000000..234c3806
--- /dev/null
+++ b/components/Chat/Feedback/Feedback.tsx
@@ -0,0 +1,298 @@
+import { IconThumbsDown, IconThumbsUp } from "@/components/Shared/SVG/Icons";
+import { SyntheticEvent, useContext, useRef, useState } from "react";
+
+import Announcement from "@/components/Shared/Announcement";
+import { Button } from "@nulib/design-system";
+import ChatFeedbackOptIn from "@/components/Chat/Feedback/OptIn";
+import ChatFeedbackOption from "@/components/Chat/Feedback/Option";
+import ChatFeedbackTextArea from "@/components/Chat/Feedback/TextArea";
+import Container from "@/components/Shared/Container";
+import { DC_URL } from "@/lib/constants/endpoints";
+import Icon from "@/components/Shared/Icon";
+import { UserContext } from "@/context/user-context";
+import { handleChatFeedbackRequest } from "@/lib/chat-helpers";
+import { styled } from "@/stitches.config";
+import { useSearchState } from "@/context/search-context";
+
+type ChatFeedbackSentiment = "positive" | "negative" | "";
+
+type ChatFeedbackFormPayload = {
+ sentiment: ChatFeedbackSentiment;
+ feedback: {
+ options: string[];
+ text: string;
+ email: string;
+ };
+ context: {
+ ref: string;
+ question: string;
+ answer: string;
+ source_documents: string[];
+ };
+};
+
+const defaultSubmittedState = {
+ completed: false,
+ sentiment: "",
+};
+
+const ChatFeedback = () => {
+ const formRef = useRef(null);
+
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isSubmitted, setIsSubmitted] = useState(defaultSubmittedState);
+
+ const [isError, setIsError] = useState(false);
+
+ const {
+ searchState: {
+ chat: { question, answer, documents, ref },
+ },
+ } = useSearchState();
+
+ const { user } = useContext(UserContext);
+ const userEmail = user?.email || "";
+
+ const initialPayload: ChatFeedbackFormPayload = {
+ sentiment: "",
+ feedback: {
+ options: [],
+ text: "",
+ email: "",
+ },
+ context: {
+ ref,
+ question,
+ answer,
+ source_documents: documents.map(({ id }) => `${DC_URL}/items/${id}`),
+ },
+ };
+
+ async function handleSubmit() {
+ const formData = formRef.current?.elements || [];
+ const payload = { ...initialPayload };
+
+ // Handle sentiment
+ if (isSubmitted.sentiment) {
+ payload.sentiment = isSubmitted.sentiment as ChatFeedbackSentiment;
+ }
+
+ for (let i of formData) {
+ if (i instanceof HTMLInputElement) {
+ // Handle checkbox values
+ if (i.name === "email") {
+ payload.feedback.email = userEmail;
+ } else {
+ if (i.checked) {
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/labels
+ const labels = Array.from(i?.labels!);
+ const label = labels.map((l) => l.textContent).join("");
+
+ payload.feedback.options.push(label);
+ }
+ }
+ }
+
+ // Handle textarea element
+ if (i instanceof HTMLTextAreaElement) {
+ payload.feedback.text = i.value || "";
+ }
+ }
+
+ setIsSubmitted({ ...isSubmitted, completed: true });
+
+ const response = await handleChatFeedbackRequest(payload);
+ response.err && handleError();
+ }
+
+ const handleSentimentSubmission = async (
+ e: SyntheticEvent,
+ ) => {
+ const sentiment = e.currentTarget.value;
+ if (!sentiment) return;
+
+ if (sentiment === "negative") setIsExpanded(true);
+
+ setIsSubmitted({
+ ...isSubmitted,
+ completed: sentiment === "positive",
+ sentiment,
+ });
+
+ const payload = {
+ ...initialPayload,
+ sentiment,
+ };
+
+ const response = await handleChatFeedbackRequest(payload);
+ response.err && handleError();
+ };
+
+ function handleError() {
+ setIsError(true);
+ setIsSubmitted(defaultSubmittedState);
+ setIsExpanded(false);
+ }
+
+ return (
+
+
+
+ Was this answer helpful?
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isError && (
+
+ There was an error submitting the feedback form
+
+ )}
+
+ {isSubmitted.completed ? (
+
+ Thank you for your submission.
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+/* eslint-disable sort-keys */
+const StyledChatFeedbackActivate = styled("div", {
+ margin: "0 0 $gr2 ",
+ display: "flex",
+ alignItems: "center",
+ fontSize: "$gr3",
+ gap: "$gr2",
+});
+
+const StyledChatFeedbackConfirmation = styled("div", {
+ fontSize: "$gr3",
+});
+
+const StyledChatFeedbackForm = styled("form", {
+ margin: "$gr3 0",
+ transition: "200ms all ease-in-out",
+ width: "61.8%",
+
+ variants: {
+ isExpanded: {
+ true: {
+ opacity: "1",
+ height: "auto",
+ },
+ false: {
+ opacity: "0",
+ height: "0",
+ },
+ },
+ },
+});
+
+const StyledChatFeedback = styled("div", {
+ variants: {
+ isSubmitted: {
+ true: {
+ [`& ${StyledChatFeedbackActivate}`]: {
+ display: "none",
+ },
+ },
+ false: {},
+ },
+ },
+});
+
+const StyledSentimentButton = styled("button", {
+ backgroundColor: "$purple10",
+ border: "none",
+ padding: 0,
+ height: "40px",
+ width: "40px",
+ display: "inline-flex",
+ flexDirection: "column",
+ justifyContent: "center",
+ alignItems: "center",
+ textAlign: "center",
+ borderRadius: "50%",
+
+ "> span": {
+ height: "36px",
+ width: "36px",
+ },
+
+ "&:not([disabled])": {
+ cursor: "pointer",
+
+ "> span": {
+ fill: "$purple60 !important",
+ },
+ },
+
+ "&[data-is-selected=true]": {
+ "> span": {
+ fill: "$purple120",
+ },
+ },
+
+ "&[data-is-selected=false]": {
+ "> span": {
+ fill: "$purple30",
+ },
+ },
+});
+
+export default ChatFeedback;
diff --git a/components/Chat/Feedback/OptIn.test.tsx b/components/Chat/Feedback/OptIn.test.tsx
new file mode 100644
index 00000000..706a0648
--- /dev/null
+++ b/components/Chat/Feedback/OptIn.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from "@testing-library/react";
+
+import ChatFeedbackOptIn from "@/components/Chat/Feedback/OptIn";
+import React from "react";
+import { UserContext } from "@/context/user-context";
+
+const mockUserContextValue = {
+ user: {
+ email: "foo@bar.com",
+ isLoggedIn: true,
+ isReadingRoom: false,
+ name: "foo",
+ sub: "123",
+ },
+};
+
+describe("ChatFeedbackOptIn", () => {
+ it("renders a checkbox input with the user email value", () => {
+ render(
+
+
+ ,
+ );
+
+ const checkbox = screen.getByRole("checkbox");
+ expect(checkbox).toHaveAttribute("value", "foo@bar.com");
+ expect(checkbox).toBeInTheDocument();
+ });
+
+ it("renders a label", () => {
+ render(
+
+
+ ,
+ );
+ const label = screen.getByText(/please follow up with me/i);
+ expect(label).toBeInTheDocument();
+ });
+});
diff --git a/components/Chat/Feedback/OptIn.tsx b/components/Chat/Feedback/OptIn.tsx
new file mode 100644
index 00000000..724a22e1
--- /dev/null
+++ b/components/Chat/Feedback/OptIn.tsx
@@ -0,0 +1,23 @@
+import { UserContext } from "@/context/user-context";
+import { styled } from "@/stitches.config";
+import { useContext } from "react";
+
+const ChatFeedbackOptIn = () => {
+ const { user } = useContext(UserContext);
+
+ return (
+
+ Please follow
+ up with me regarding this issue.
+
+ );
+};
+
+/* eslint-disable sort-keys */
+const StyledChatFeedbackOptIn = styled("label", {
+ display: "block",
+ margin: "$gr3 0",
+ fontSize: "$gr2",
+});
+
+export default ChatFeedbackOptIn;
diff --git a/components/Chat/Feedback/Option.test.tsx b/components/Chat/Feedback/Option.test.tsx
new file mode 100644
index 00000000..dcd0c3ea
--- /dev/null
+++ b/components/Chat/Feedback/Option.test.tsx
@@ -0,0 +1,21 @@
+// test ChatFeedbackOption.tsx
+
+import { render, screen } from "@testing-library/react";
+
+import ChatFeedbackOption from "@/components/Chat/Feedback/Option";
+
+describe("ChatFeedbackOption", () => {
+ it("renders a checkbox input", () => {
+ render();
+ const checkbox = screen.getByTestId("chat-feedback-option-test");
+ expect(checkbox).toHaveAttribute("aria-checked", "false");
+ expect(checkbox).toHaveAttribute("tabindex", "0");
+ expect(checkbox).toBeInTheDocument();
+ });
+
+ it("renders a label", () => {
+ render();
+ const label = screen.getByText(/this is a test/i);
+ expect(label).toBeInTheDocument();
+ });
+});
diff --git a/components/Chat/Feedback/Option.tsx b/components/Chat/Feedback/Option.tsx
new file mode 100644
index 00000000..a5fb0ea3
--- /dev/null
+++ b/components/Chat/Feedback/Option.tsx
@@ -0,0 +1,88 @@
+import { useRef, useState } from "react";
+
+import { styled } from "@/stitches.config";
+
+const ChatFeedbackOption = ({
+ name,
+ label,
+}: {
+ name: string;
+ label: string;
+}) => {
+ const [isChecked, setIsChecked] = useState(false);
+ const inputRef = useRef(null);
+
+ function handleOnChange() {
+ setIsChecked(inputRef?.current?.checked ?? false);
+ }
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === " ") {
+ event.preventDefault();
+ setIsChecked(!isChecked);
+ }
+ };
+
+ const handleClick = () => {
+ setIsChecked(!isChecked);
+ };
+
+ return (
+
+
+ {label}
+
+ );
+};
+
+/* eslint-disable sort-keys */
+const StyledChatFeedbackOption = styled("label", {
+ display: "inline-flex",
+ alignItems: "center",
+ fontSize: "$gr2",
+ margin: "0 $gr1 $gr1 0",
+ borderRadius: "1rem",
+ cursor: "pointer",
+ transition: "$dcAll",
+ padding: "$gr1 $gr2",
+ gap: "3px",
+
+ "&:hover": {
+ boxShadow: "3px 3px 8px #0002",
+ },
+
+ input: {
+ display: "none",
+ },
+
+ variants: {
+ isChecked: {
+ true: {
+ color: "$white",
+ border: "1px solid $black80",
+ backgroundColor: "$black80",
+ },
+ false: {
+ color: "$black50",
+ border: "1px solid $black20",
+ backgroundColor: "$white",
+ },
+ },
+ },
+});
+
+export default ChatFeedbackOption;
diff --git a/components/Chat/Feedback/TextArea.tsx b/components/Chat/Feedback/TextArea.tsx
new file mode 100644
index 00000000..36846755
--- /dev/null
+++ b/components/Chat/Feedback/TextArea.tsx
@@ -0,0 +1,27 @@
+import { styled } from "@/stitches.config";
+
+const ChatFeedbackTextArea = () => {
+ return (
+
+ Add additional specific details (optional)
+
+
+ );
+};
+
+const StyledChatFeedbackTextArea = styled("label", {
+ display: "flex",
+ flexDirection: "column",
+ margin: "$gr3 0",
+
+ span: {
+ fontSize: "$gr2",
+ marginBottom: "$gr1",
+ },
+
+ textarea: {
+ resize: "none",
+ },
+});
+
+export default ChatFeedbackTextArea;
diff --git a/components/Chat/Response/Images.test.tsx b/components/Chat/Response/Images.test.tsx
new file mode 100644
index 00000000..48c2b270
--- /dev/null
+++ b/components/Chat/Response/Images.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen, waitFor } from "@testing-library/react";
+
+import ResponseImages from "@/components/Chat/Response/Images";
+import { sampleWork1 } from "@/mocks/sample-work1";
+import { sampleWork2 } from "@/mocks/sample-work2";
+
+describe("ResponseImages", () => {
+ it.skip("renders the component", async () => {
+ const sourceDocuments = [sampleWork1, sampleWork2];
+
+ render(
+ ,
+ );
+
+ sourceDocuments.forEach(async (doc) => {
+ // check that the item is not in the document on initial render
+ expect(screen.queryByText(`${doc?.title}`)).not.toBeInTheDocument();
+
+ // check that the items are in the document after 1 second
+ await waitFor(
+ () => {
+ expect(screen.getByText(`${doc?.title}`)).toBeInTheDocument();
+ },
+ { timeout: 1000 },
+ );
+ });
+ });
+});
diff --git a/components/Chat/Response/Images.tsx b/components/Chat/Response/Images.tsx
new file mode 100644
index 00000000..1c7acbb0
--- /dev/null
+++ b/components/Chat/Response/Images.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useState } from "react";
+
+import GridItem from "@/components/Grid/Item";
+import { StyledImages } from "@/components/Chat/Response/Response.styled";
+import { Work } from "@nulib/dcapi-types";
+
+const ResponseImages = ({
+ isStreamingComplete,
+ sourceDocuments,
+}: {
+ isStreamingComplete: boolean;
+ sourceDocuments: Work[];
+}) => {
+ const [nextIndex, setNextIndex] = useState(0);
+
+ useEffect(() => {
+ if (isStreamingComplete) {
+ setNextIndex(sourceDocuments.length);
+ return;
+ }
+
+ if (nextIndex < sourceDocuments.length) {
+ const timer = setTimeout(() => {
+ setNextIndex(nextIndex + 1);
+ }, 382);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isStreamingComplete, nextIndex, sourceDocuments.length]);
+
+ return (
+
+ {sourceDocuments.slice(0, nextIndex).map((document: Work) => (
+
+ ))}
+
+ );
+};
+
+export default ResponseImages;
diff --git a/components/Chat/Response/Response.styled.tsx b/components/Chat/Response/Response.styled.tsx
new file mode 100644
index 00000000..999b517f
--- /dev/null
+++ b/components/Chat/Response/Response.styled.tsx
@@ -0,0 +1,183 @@
+import { keyframes, styled } from "@/stitches.config";
+
+/* eslint sort-keys: 0 */
+
+const CursorKeyframes = keyframes({
+ "50%": {
+ opacity: 0,
+ },
+});
+
+const StyledResponse = styled("section", {
+ display: "flex",
+ position: "relative",
+ gap: "$gr5",
+ zIndex: "0",
+ minHeight: "50vh",
+
+ "h1, h2, h3, h4, h5, h6, strong": {
+ fontFamily: "$northwesternSansBold",
+ },
+
+ "@sm": {
+ flexDirection: "column",
+ gap: "$gr3",
+ marginBottom: "$gr4",
+ },
+});
+
+const StyledResponseAside = styled("aside", {
+ width: "38.2%",
+ flexShrink: 0,
+ borderRadius: "inherit",
+ borderTopLeftRadius: "unset",
+ borderBottomLeftRadius: "unset",
+
+ "@sm": {
+ width: "unset",
+ },
+});
+
+const StyledResponseContent = styled("div", {
+ width: "61.8%",
+ flexGrow: 0,
+
+ "@sm": {
+ width: "unset",
+ },
+});
+
+const StyledResponseWrapper = styled("div", {
+ padding: "0",
+});
+
+const StyledImages = styled("div", {
+ display: "flex",
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: "$gr4",
+
+ "> div": {
+ width: "calc(33% - 20px)",
+
+ "@md": {
+ width: "calc(50% - 20px)",
+ },
+
+ "@sm": {
+ width: "calc(33% - 20px)",
+ },
+
+ "&:nth-child(1)": {
+ width: "calc(66% - 10px)",
+
+ "@md": {
+ width: "100%",
+ },
+
+ "@sm": {
+ width: "calc(33% - 20px)",
+ },
+ },
+
+ figure: {
+ padding: "0",
+
+ "> div": {
+ boxShadow: "5px 5px 13px rgba(0, 0, 0, 0.25)",
+ },
+
+ figcaption: {
+ "span:first-of-type": {
+ textOverflow: "ellipsis",
+ display: "-webkit-box",
+ WebkitLineClamp: "3",
+ WebkitBoxOrient: "vertical",
+ overflow: "hidden",
+ },
+ },
+ },
+ },
+});
+
+const StyledQuestion = styled("h3", {
+ fontFamily: "$northwesternSansBold",
+ fontWeight: "400",
+ fontSize: "$gr6",
+ letterSpacing: "-0.012em",
+ lineHeight: "1.35em",
+ margin: "0",
+ padding: "0 0 $gr4 0",
+ color: "$black",
+});
+
+const StyledStreamedAnswer = styled("article", {
+ fontSize: "$gr3",
+ lineHeight: "162.8%",
+ overflow: "hidden",
+
+ "h1, h2, h3, h4, h5, h6, strong": {
+ fontWeight: "400",
+ fontFamily: "$northwesternSansBold",
+ fontSizeAdjust: "none",
+ },
+
+ a: {
+ textDecoration: "underline",
+ textDecorationThickness: "min(2px,max(1px,.05em))",
+ textUnderlineOffset: "calc(.05em + 2px)",
+ textDecorationColor: "$purple10",
+ },
+
+ "span.markdown-cursor": {
+ position: "relative",
+ marginLeft: "$gr1",
+
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ top: "-5px",
+ width: "9px",
+ height: "1.38em",
+ backgroundColor: "$black20",
+ animation: `${CursorKeyframes} 1s linear infinite`,
+ },
+ },
+});
+
+const StyledResponseActions = styled("div", {
+ display: "flex",
+ gap: "$gr2",
+ padding: "$gr4 0",
+});
+
+const StyledUnsubmitted = styled("p", {
+ color: "$black50",
+ fontSize: "$gr3",
+ fontFamily: "$northwesternSansLight",
+ textAlign: "center",
+ width: "61.8%",
+ maxWidth: "61.8%",
+ margin: "0 auto",
+ padding: "$gr4 0",
+});
+
+const StyledResponseDisclaimer = styled("p", {
+ color: "$black50",
+ fontSize: "$gr2",
+ fontFamily: "$northwesternSansLight",
+ margin: "0 0 $gr4",
+});
+
+export {
+ StyledResponse,
+ StyledResponseActions,
+ StyledResponseAside,
+ StyledResponseContent,
+ StyledResponseDisclaimer,
+ StyledResponseWrapper,
+ StyledImages,
+ StyledQuestion,
+ StyledStreamedAnswer,
+ StyledUnsubmitted,
+};
diff --git a/components/Chat/Response/Response.tsx b/components/Chat/Response/Response.tsx
new file mode 100644
index 00000000..74beb57c
--- /dev/null
+++ b/components/Chat/Response/Response.tsx
@@ -0,0 +1,58 @@
+import {
+ StyledQuestion,
+ StyledResponse,
+ StyledResponseAside,
+ StyledResponseContent,
+ StyledResponseWrapper,
+} from "./Response.styled";
+
+import BouncingLoader from "@/components/Shared/BouncingLoader";
+import Container from "@/components/Shared/Container";
+import React from "react";
+import ResponseImages from "@/components/Chat/Response/Images";
+import ResponseStreamedAnswer from "@/components/Chat/Response/StreamedAnswer";
+import { Work } from "@nulib/dcapi-types";
+
+interface ChatResponseProps {
+ isStreamingComplete: boolean;
+ searchTerm: string;
+ sourceDocuments: Work[];
+ streamedAnswer?: string;
+}
+
+const ChatResponse: React.FC = ({
+ isStreamingComplete,
+ searchTerm,
+ sourceDocuments,
+ streamedAnswer,
+}) => {
+ return (
+
+
+
+
+ {searchTerm}
+ {streamedAnswer ? (
+
+ ) : (
+
+ )}
+
+ {sourceDocuments.length > 0 && (
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default ChatResponse;
diff --git a/components/Chat/Response/StreamedAnswer.tsx b/components/Chat/Response/StreamedAnswer.tsx
new file mode 100644
index 00000000..7f55135f
--- /dev/null
+++ b/components/Chat/Response/StreamedAnswer.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { StyledStreamedAnswer } from "@/components/Chat/Response/Response.styled";
+import useMarkdown from "@nulib/use-markdown";
+
+const cursor = "";
+
+const ResponseStreamedAnswer = ({
+ isStreamingComplete,
+ streamedAnswer,
+}: {
+ isStreamingComplete: boolean;
+ streamedAnswer: string;
+}) => {
+ const preparedMarkdown = !isStreamingComplete
+ ? streamedAnswer + cursor
+ : streamedAnswer;
+
+ const { html } = useMarkdown(preparedMarkdown);
+
+ const cursorRegex = new RegExp(cursor, "g");
+ const updatedHtml = !isStreamingComplete
+ ? html.replace(cursorRegex, ``)
+ : html;
+
+ return (
+
+
+
+ );
+};
+
+export default ResponseStreamedAnswer;
diff --git a/components/Clover/ViewerWrapper.styled.ts b/components/Clover/ViewerWrapper.styled.ts
index 018f2f62..2d580346 100644
--- a/components/Clover/ViewerWrapper.styled.ts
+++ b/components/Clover/ViewerWrapper.styled.ts
@@ -5,10 +5,9 @@ import { styled } from "@/stitches.config";
const ViewerWrapperStyled = styled("section", {
position: "relative",
zIndex: "1",
- margin: "1px 0 0 0",
- "[class*='-css']": {
- boxShadow: "3px 3px 11px #0002",
+ ".clover-viewer-painting": {
+ background: "#f0f0f0",
},
"& label[for='information-toggle']": {
diff --git a/components/Facets/Facet/Options.tsx b/components/Facets/Facet/Options.tsx
index 75aa0c5c..f3b0abd7 100644
--- a/components/Facets/Facet/Options.tsx
+++ b/components/Facets/Facet/Options.tsx
@@ -4,6 +4,7 @@ import {
} from "@/types/api/response";
import { Options, SpinWrapper } from "./GenericFacet.styled";
import { useEffect, useState } from "react";
+
import { ApiSearchRequestBody } from "@/types/api/request";
import { DC_API_SEARCH_URL } from "@/lib/constants/endpoints";
import { FacetsInstance } from "@/types/components/facets";
@@ -12,6 +13,7 @@ import { SpinLoader } from "@/components/Shared/Loader.styled";
import { apiPostRequest } from "@/lib/dc-api";
import { buildQuery } from "@/lib/queries/builder";
import { useFilterState } from "@/context/filter-context";
+import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
import useQueryParams from "@/hooks/useQueryParams";
interface FacetOptionsProps {
@@ -27,6 +29,8 @@ const FacetOptions: React.FC = ({
const [aggregations, setAggregations] = useState();
const [loading, setLoading] = useState(true);
+ const { isChecked } = useGenerativeAISearchToggle();
+
const {
filterState: { userFacetsUnsubmitted },
} = useFilterState();
@@ -35,13 +39,16 @@ const FacetOptions: React.FC = ({
if (typeof searchTerm === "undefined") return;
(async () => {
try {
- const body: ApiSearchRequestBody = buildQuery({
- aggs: facet ? [facet] : undefined,
- aggsFilterValue: aggsFilterValue,
- size: 1,
- term: searchTerm,
- urlFacets: userFacetsUnsubmitted,
- });
+ const body: ApiSearchRequestBody = buildQuery(
+ {
+ aggs: facet ? [facet] : undefined,
+ aggsFilterValue: aggsFilterValue,
+ size: 1,
+ term: searchTerm,
+ urlFacets: userFacetsUnsubmitted,
+ },
+ !!isChecked,
+ );
const response = await apiPostRequest({
body: body,
diff --git a/components/Facets/Facets.styled.ts b/components/Facets/Facets.styled.ts
index 39f5961d..659c10f7 100644
--- a/components/Facets/Facets.styled.ts
+++ b/components/Facets/Facets.styled.ts
@@ -13,9 +13,7 @@ const StyledFacets = styled("div", {
zIndex: "1",
[`& ${WorkTypeWrapper}`]: {
- borderRight: "1px solid $black10",
paddingRight: "$gr2",
- transition: "$dcWidth",
"@sm": {
marginTop: "$gr3",
@@ -25,7 +23,6 @@ const StyledFacets = styled("div", {
},
"@sm": {
- padding: "$gr4 0",
flexDirection: "column",
alignItems: "center",
},
@@ -49,16 +46,11 @@ const FacetExtras = styled("div", {
const Wrapper = styled("div", {
height: "38px",
transition: "$dcScrollHeight",
- margin: "$gr4 0",
-
- "@sm": {
- margin: "-$gr4 0 $gr4",
- backgroundColor: "$gray6",
- height: "225px",
- },
+ margin: "0 0 $gr4",
".facets-ui-container": {
transition: "$dcAll",
+ overflow: "hidden",
},
"&[data-filter-fixed='true']": {
diff --git a/components/Facets/Facets.tsx b/components/Facets/Facets.tsx
index 32726385..fb4453f8 100644
--- a/components/Facets/Facets.tsx
+++ b/components/Facets/Facets.tsx
@@ -1,24 +1,39 @@
import { FacetExtras, StyledFacets, Width, Wrapper } from "./Facets.styled";
-import React, { useRef } from "react";
+import React, { useEffect, useRef, useState } from "react";
import Container from "@/components/Shared/Container";
import FacetsFilter from "@/components/Facets/Filter/Filter";
import SearchPublicOnlyWorks from "@/components/Search/PublicOnlyWorks";
import WorkType from "@/components/Facets/WorkType/WorkType";
-import { useSearchState } from "@/context/search-context";
const Facets: React.FC = () => {
- const {
- searchState: { searchFixed },
- } = useSearchState();
+ const wrapperRef = useRef(null);
+ const [isFixed, setIsFixed] = useState(false);
const facetsRef = useRef(null);
+ useEffect(() => {
+ const handleResize = () => {
+ if (wrapperRef.current) {
+ const rect = wrapperRef.current.getBoundingClientRect();
+ setIsFixed(rect.top < 70);
+ }
+ };
+
+ handleResize();
+
+ window.addEventListener("scroll", handleResize);
+
+ return () => {
+ window.removeEventListener("scroll", handleResize);
+ };
+ }, []);
+
return (
-
+
diff --git a/components/Facets/Filter/Clear.styled.ts b/components/Facets/Filter/Clear.styled.ts
index 7075e488..b6a25b96 100644
--- a/components/Facets/Filter/Clear.styled.ts
+++ b/components/Facets/Filter/Clear.styled.ts
@@ -3,7 +3,7 @@ import { styled } from "@/stitches.config";
/* eslint sort-keys: 0 */
const FilterClearStyled = styled("button", {
- background: "$white",
+ background: "transparent",
border: "none",
borderRadius: "50px",
color: "$black50",
@@ -26,6 +26,7 @@ const FilterClearStyled = styled("button", {
variants: {
isFixed: {
true: {
+ backgroundColor: "$white",
boxShadow: "2px 2px 5px #0002",
},
},
diff --git a/components/Facets/Filter/Clear.tsx b/components/Facets/Filter/Clear.tsx
index e4236c9b..1a0dbb14 100644
--- a/components/Facets/Filter/Clear.tsx
+++ b/components/Facets/Filter/Clear.tsx
@@ -37,7 +37,7 @@ const FilterClear: React.FC = ({ isModal = false }) => {
isFixed={searchFixed}
isModal={isModal}
>
- Clear All
+ Reset
);
};
diff --git a/components/Facets/Filter/Filter.styled.ts b/components/Facets/Filter/Filter.styled.ts
index 5692fbe5..1e0c4a90 100644
--- a/components/Facets/Filter/Filter.styled.ts
+++ b/components/Facets/Filter/Filter.styled.ts
@@ -1,4 +1,5 @@
import * as Dialog from "@radix-ui/react-dialog";
+
import { IconStyled } from "@/components/Shared/Icon";
import { ValueWrapper } from "@/components/Facets/UserFacets/UserFacets.styled";
import { styled } from "@/stitches.config";
@@ -15,11 +16,12 @@ const FilterActivate = styled(Dialog.Trigger, {
backgroundColor: "$purple",
border: "0",
color: "$white",
- fontFamily: "$northwesternSansBold",
+ fontFamily: "$northwesternSansRegular",
fontSize: "$gr3",
borderRadius: "50px",
transition: "$dcAll",
padding: "0 $gr3 0 $gr1",
+ boxShadow: "2px 2px 5px #0002",
[`& ${IconStyled}`]: {
color: "$purple60",
@@ -29,7 +31,6 @@ const FilterActivate = styled(Dialog.Trigger, {
"&:hover": {
backgroundColor: "$purple120",
color: "$white",
- boxShadow: "2px 2px 2px #0002",
[`& ${IconStyled}`]: {
color: "$white",
@@ -46,14 +47,6 @@ const FilterFloating = styled("div", {
boxShadow: "2px 2px 5px #0002",
borderRadius: "50px",
transition: "$dcAll",
-
- [`& ${FilterActivate}`]: {
- boxShadow: "2px 2px 5px #0002",
- },
-
- "&:hover": {
- boxShadow: "2px 2px 5px #0004",
- },
});
const FilterClose = styled(Dialog.Close, {});
diff --git a/components/Facets/Filter/Modal.tsx b/components/Facets/Filter/Modal.tsx
index ef4403af..7c67e33d 100644
--- a/components/Facets/Filter/Modal.tsx
+++ b/components/Facets/Filter/Modal.tsx
@@ -1,4 +1,5 @@
import * as Dialog from "@radix-ui/react-dialog";
+
import {
FilterBody,
FilterBodyInner,
@@ -7,6 +8,7 @@ import {
FilterHeader,
} from "@/components/Facets/Filter/Filter.styled";
import React, { useEffect, useState } from "react";
+
import { ApiSearchRequestBody } from "@/types/api/request";
import { ApiSearchResponse } from "@/types/api/response";
import { DC_API_SEARCH_URL } from "@/lib/constants/endpoints";
@@ -19,6 +21,7 @@ import Preview from "./Preview";
import { apiPostRequest } from "@/lib/dc-api";
import { buildQuery } from "@/lib/queries/builder";
import { useFilterState } from "@/context/filter-context";
+import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
export type SetIsModalOpenType = React.Dispatch>;
@@ -33,13 +36,18 @@ const FilterModal: React.FC = ({ q, setIsModalOpen }) => {
filterState: { userFacetsUnsubmitted },
} = useFilterState();
+ const { isChecked } = useGenerativeAISearchToggle();
+
useEffect(() => {
async function getData() {
- const body: ApiSearchRequestBody = buildQuery({
- size: 5,
- term: q as string,
- urlFacets: userFacetsUnsubmitted,
- });
+ const body: ApiSearchRequestBody = buildQuery(
+ {
+ size: 5,
+ term: q as string,
+ urlFacets: userFacetsUnsubmitted,
+ },
+ !!isChecked,
+ );
const response = await apiPostRequest({
body: body,
diff --git a/components/Facets/Filter/Submit.tsx b/components/Facets/Filter/Submit.tsx
index 3161f10a..4920ffcc 100644
--- a/components/Facets/Filter/Submit.tsx
+++ b/components/Facets/Filter/Submit.tsx
@@ -1,6 +1,7 @@
import { Button } from "@nulib/design-system";
import { SetIsModalOpenType } from "@/components/Facets/Filter/Modal";
import { useFilterState } from "@/context/filter-context";
+import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
import useQueryParams from "@/hooks/useQueryParams";
import { useRouter } from "next/router";
@@ -14,6 +15,7 @@ const FacetsFilterSubmit: React.FC = ({
total,
}) => {
const router = useRouter();
+ const { isChecked: isAI } = useGenerativeAISearchToggle();
const {
query: { q },
} = router;
@@ -44,6 +46,8 @@ const FacetsFilterSubmit: React.FC = ({
setIsModalOpen(false);
};
+ console.log(`isAI`, isAI);
+
return (
);
diff --git a/components/Facets/UserFacets/UserFacets.styled.ts b/components/Facets/UserFacets/UserFacets.styled.ts
index a163e29d..c1a28e4f 100644
--- a/components/Facets/UserFacets/UserFacets.styled.ts
+++ b/components/Facets/UserFacets/UserFacets.styled.ts
@@ -1,11 +1,12 @@
import * as Dropdown from "@radix-ui/react-dropdown-menu";
+
import { styled } from "@/stitches.config";
/* eslint sort-keys: 0 */
const DropdownToggle = styled(Dropdown.Trigger, {
display: "flex",
- padding: "0 $gr2",
+ padding: "0 $gr1",
border: "none",
position: "relative",
backgroundColor: "transparent",
@@ -21,7 +22,6 @@ const DropdownToggle = styled(Dropdown.Trigger, {
svg: {
width: "$gr4",
- marginBottom: "-3px",
color: "$purple120",
transform: "rotate(0deg)",
transition: "$dcAll",
@@ -41,7 +41,8 @@ const DropdownToggle = styled(Dropdown.Trigger, {
height: "19px",
borderRadius: "50%",
marginTop: "-$gr4",
- marginRight: "calc(-$gr3 + 4px)",
+ position: "absolute",
+ right: "-6px",
},
[`&:hover`]: {
diff --git a/components/Facets/UserFacets/UserFacets.test.tsx b/components/Facets/UserFacets/UserFacets.test.tsx
index 1a320d37..d95f6eaf 100644
--- a/components/Facets/UserFacets/UserFacets.test.tsx
+++ b/components/Facets/UserFacets/UserFacets.test.tsx
@@ -1,20 +1,29 @@
-// import { render, screen, within } from "@/test-utils";
import { act, render, renderHook, screen } from "@testing-library/react";
import singletonRouter, { useRouter } from "next/router";
+
import { FilterProvider } from "@/context/filter-context";
import React from "react";
+import { SearchContextStore } from "@/types/context/search-context";
import { SearchProvider } from "@/context/search-context";
import UserFacets from "./UserFacets";
-const searchStateDefault = {
+const searchStateDefault: SearchContextStore = {
aggregations: {},
- q: "",
+ chat: {
+ answer: "",
+ documents: [],
+ question: "",
+ },
searchFixed: false,
};
-const searchState = {
+const searchState: SearchContextStore = {
aggregations: {},
- q: "",
+ chat: {
+ answer: "",
+ documents: [],
+ question: "",
+ },
searchFixed: false,
};
@@ -76,9 +85,11 @@ describe("UserFacet UI component", () => {
);
const userFacets = await screen.findByTestId(`facet-user-component`);
expect(userFacets).toBeInTheDocument();
+
const toggle = screen.getByTestId(`facet-user-component-popover-toggle`);
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveTextContent("1");
+
const content = screen.queryByText(`facet-user-component-popover-content`);
expect(content).toBeNull();
});
@@ -103,8 +114,10 @@ describe("UserFacet UI component", () => {
,
);
+
const userFacets = screen.getByTestId(`facet-user-component`);
expect(userFacets).toBeInTheDocument();
+
const values = screen.getAllByTestId(`facet-user-value-component`);
expect(values.length).toBe(3);
});
diff --git a/components/Facets/UserFacets/UserFacets.tsx b/components/Facets/UserFacets/UserFacets.tsx
index 370deaa7..5e4ca336 100644
--- a/components/Facets/UserFacets/UserFacets.tsx
+++ b/components/Facets/UserFacets/UserFacets.tsx
@@ -1,4 +1,5 @@
import * as Dropdown from "@radix-ui/react-dropdown-menu";
+
import {
DropdownContent,
DropdownToggle,
@@ -6,6 +7,7 @@ import {
} from "./UserFacets.styled";
import React, { useRef } from "react";
import { useEffect, useState } from "react";
+
import FacetsCurrentUserValue from "@/components/Facets/UserFacets/Value";
import { IconChevronDown } from "@/components/Shared/SVG/Icons";
import { UrlFacets } from "@/types/context/filter-context";
@@ -29,14 +31,16 @@ const FacetsCurrentUser: React.FC = ({
screen,
urlFacets,
}) => {
- const [currentOptions, setCurrentOptions] = useState([]);
- const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
+
const { filterDispatch, filterState } = useFilterState();
const {
searchState: { searchFixed },
} = useSearchState();
+ const [currentOptions, setCurrentOptions] = useState([]);
+ const [isOpen, setIsOpen] = useState(false);
+
const dropdownRef = useRef(null);
const handleOpenChange = (status: boolean) => setIsOpen(status);
@@ -84,7 +88,7 @@ const FacetsCurrentUser: React.FC = ({
if (instance && facets) {
const { id, value } = instance;
const {
- query: { q },
+ query: { q, ai },
} = router;
const newObj: UrlFacets = { ...facets };
@@ -93,7 +97,11 @@ const FacetsCurrentUser: React.FC = ({
if (screen === "search")
router.push({
pathname: "/search",
- query: { ...(q && { q }), ...newObj },
+ query: {
+ ...(q && { q }),
+ ...newObj,
+ ...(ai && { ai }),
+ },
});
if (screen === "modal")
diff --git a/components/Facets/WorkType/WorkType.styled.ts b/components/Facets/WorkType/WorkType.styled.ts
index 69fb28b2..7dd774a7 100644
--- a/components/Facets/WorkType/WorkType.styled.ts
+++ b/components/Facets/WorkType/WorkType.styled.ts
@@ -1,4 +1,5 @@
import * as Radio from "@radix-ui/react-radio-group";
+
import { styled } from "@/stitches.config";
/* eslint sort-keys: 0 */
@@ -50,9 +51,13 @@ const StyledWorkType = styled("ul", {
justifyContent: "center",
alignItems: "center",
alignContent: "center",
- height: "2rem",
+ height: "$gr2",
cursor: "pointer",
- padding: "0 1rem",
+ padding: "0 $gr3",
+
+ "@md": {
+ padding: "0 $gr2",
+ },
},
},
},
diff --git a/components/Facets/index.tsx b/components/Facets/index.tsx
new file mode 100644
index 00000000..06c22d15
--- /dev/null
+++ b/components/Facets/index.tsx
@@ -0,0 +1,12 @@
+import FacetsFilter from "@/components/Facets/Filter/Filter";
+import { FilterProvider } from "@/context/filter-context";
+
+const Facets: React.FC = () => {
+ return (
+
+
+
+ );
+};
+
+export default Facets;
diff --git a/components/Figure/Figure.styled.ts b/components/Figure/Figure.styled.ts
index 9bb8c791..1d2e8c54 100644
--- a/components/Figure/Figure.styled.ts
+++ b/components/Figure/Figure.styled.ts
@@ -1,5 +1,7 @@
import * as AspectRatio from "@radix-ui/react-aspect-ratio";
+
import { VariantProps, styled } from "@/stitches.config";
+
import { IconLock } from "@/components/Shared/SVG/Icons";
import Image from "next/image";
@@ -78,10 +80,10 @@ const FigurePlaceholder = styled(AspectRatio.Root, {
const FigureSupplementalInfo = styled("span", {
fontSize: "$gr2",
+ fontFamily: "$northwesternSansRegular",
color: "$black50",
marginTop: "$gr1",
display: "block",
- fontFamily: "$northwesternSansLight",
});
const FigureTitle = styled("span", {
diff --git a/components/Header/Header.styled.ts b/components/Header/Header.styled.ts
index 1398b456..a3f5248f 100644
--- a/components/Header/Header.styled.ts
+++ b/components/Header/Header.styled.ts
@@ -1,4 +1,5 @@
import { VariantProps, styled } from "@/stitches.config";
+
import { ContainerStyled } from "@/components/Shared/Container";
import { NavStyled } from "@/components/Nav/Nav.styled";
import { SearchStyled } from "@/components/Search/Search.styled";
@@ -7,13 +8,14 @@ import { SearchStyled } from "@/components/Search/Search.styled";
const Lockup = styled("div", {
position: "relative",
- padding: "$gr4 0 $gr5",
- fontSize: "$gr6",
- fontFamily: "$northwesternSansLight",
+ padding: "$gr4 0",
+ fontSize: "$gr5",
+ fontFamily: "$northwesternSansRegular",
zIndex: "1",
+ color: "$purple",
a: {
- color: "$white !important",
+ color: "inherit !important",
textDecoration: "none",
},
});
@@ -58,7 +60,6 @@ const MenuToggle = styled("button", {
const PrimaryInner = styled("div", {
display: "flex",
flexGrow: "1",
- alignItems: "center",
"@sm": {
"& nav": {
@@ -70,13 +71,13 @@ const PrimaryInner = styled("div", {
const Primary = styled("div", {
color: "$black",
display: "flex",
+ alignItems: "flex-end",
+ justifyContent: "center",
margin: "0 auto",
+ paddingBottom: "$gr4",
zIndex: "1",
- transition: "$dcAll",
position: "relative",
top: "unset",
- height: "$gr5",
- boxShadow: "2px 2px 5px #0001",
[`& ${ContainerStyled}`]: {
display: "flex",
@@ -87,20 +88,22 @@ const Primary = styled("div", {
transition: "$dcAll",
[`& ${NavStyled}`]: {
- backgroundColor: "$purple120",
- fontSize: "$gr4",
+ fontSize: "$gr3",
fontFamily: "$northwesternSansRegular",
display: "flex",
height: "$gr5",
- boxShadow: "3px -3px 19px #fff1",
a: {
- color: "$white",
+ color: "$purple",
display: "flex",
height: "100%",
justifyContent: "center",
alignItems: "center",
padding: "0 $gr3",
+ textDecoration: "underline",
+ textDecorationThickness: "min(2px,max(1px,.05em))",
+ textUnderlineOffset: "calc(.05em + 2px)",
+ textDecorationColor: "$purple10",
},
},
@@ -122,14 +125,19 @@ const Primary = styled("div", {
"&[data-search-fixed='true']": {
zIndex: "2",
+ paddingTop: "$gr5",
+
+ form: {
+ backgroundColor: "white",
+ boxShadow: "0px 5px 19px #0003",
+ borderRadius: "0",
+ },
[`& ${ContainerStyled}`]: {
position: "fixed",
top: "0",
maxWidth: "100%",
padding: "0",
- backgroundColor: "white",
- boxShadow: "0px 3px 11px #0003",
transition: "$dcAll",
"> span": {
@@ -155,7 +163,7 @@ const Primary = styled("div", {
const Super = styled("div", {
position: "relative",
- backgroundColor: "$purple120",
+ backgroundColor: "$purple",
color: "$purple10",
zIndex: "10",
@@ -195,24 +203,36 @@ const User = styled("span", {
},
});
-const HeaderStyled = styled("header", {
- backgroundColor: "$purple",
+const Logout = styled("button", {
+ border: "none",
+ background: "none",
color: "$white",
+ cursor: "pointer",
+ paddingLeft: "$gr2",
+});
+
+const HeaderStyled = styled("header", {
flexDirection: "column",
variants: {
isHero: {
true: {
+ backgroundColor: "$purple",
+
+ [`& ${Super}`]: {
+ boxShadow: "0px 5px 19px #0002",
+ },
+
[`& ${Lockup}`]: {
- textShadow: "1px 1px 3px #0003",
+ color: "$white !important",
},
- [`& ${Primary}`]: {
- boxShadow: "unset",
+ [`& ${SearchStyled}`]: {
+ background: "$white",
+ },
- [`& ${SearchStyled}, & ${NavStyled}`]: {
- boxShadow: "8px 8px 19px #0003",
- },
+ [`& ${NavStyled} a`]: {
+ color: "$white !important",
},
},
},
@@ -223,6 +243,7 @@ export type HeaderVariants = VariantProps;
export {
Lockup,
+ Logout,
Menu,
MenuToggle,
Primary,
diff --git a/components/Header/Primary.test.tsx b/components/Header/Primary.test.tsx
index f43674a5..a2c276a7 100644
--- a/components/Header/Primary.test.tsx
+++ b/components/Header/Primary.test.tsx
@@ -1,8 +1,26 @@
import { render, screen } from "@/test-utils";
-import HeaderPrimary from "./Primary";
+import HeaderPrimary from "./Primary";
+import { createDynamicRouteParser } from "next-router-mock/dynamic-routes";
import mockRouter from "next-router-mock";
+// Tell mockRouter about the dynamic routes in our app:
+mockRouter.useParser(
+ createDynamicRouteParser(["/search", "/collections/[id]"]),
+);
+
+jest.mock("@/components/Search/Search", () => {
+ return function DummySearch() {
+ return Search
;
+ };
+});
+
+jest.mock("@/components/Search/JumpTo", () => {
+ return function DummySearchJumpTo() {
+ return Jump To
;
+ };
+});
+
describe("HeaderPrimary", () => {
beforeEach(() => {
mockRouter.setCurrentUrl("/search");
@@ -13,4 +31,26 @@ describe("HeaderPrimary", () => {
const wrapper = screen.getByTestId("header-primary-ui-component");
expect(wrapper).toBeInTheDocument();
});
+
+ it("renders the search component", () => {
+ render();
+ const search = screen.getByTestId("search-ui-component");
+ expect(search).toBeInTheDocument();
+ });
+
+ it("renders the search jump to component", () => {
+ mockRouter.setCurrentUrl(
+ "https://devbox.library.northwestern.edu:3000/collections/1c2e2200-c12d-4c7f-8b87-a935c349898a",
+ );
+ render();
+
+ expect(screen.queryByTestId("search-ui-component")).toBeNull();
+ expect(screen.getByTestId("search-jump-to")).toBeInTheDocument();
+ });
+
+ it("renders browse collections link", () => {
+ render();
+ const link = screen.getByText("Browse Collections");
+ expect(link).toBeInTheDocument();
+ });
});
diff --git a/components/Header/Primary.tsx b/components/Header/Primary.tsx
index 9ee319a1..e15fd0d5 100644
--- a/components/Header/Primary.tsx
+++ b/components/Header/Primary.tsx
@@ -1,8 +1,7 @@
import { Primary, PrimaryInner } from "@/components/Header/Header.styled";
import { useEffect, useRef, useState } from "react";
-import { AcademicN } from "@/components/Shared/SVG/Northwestern";
+
import Container from "@/components/Shared/Container";
-import Heading from "@/components/Heading/Heading";
import Link from "next/link";
import Nav from "@/components/Nav/Nav";
import Search from "@/components/Search/Search";
@@ -48,9 +47,6 @@ const HeaderPrimary: React.FC = () => {
ref={primaryRef}
>
-
-
-
{!isCollectionPage && (
diff --git a/components/Header/Super.tsx b/components/Header/Super.tsx
index 8724e606..dcafb641 100644
--- a/components/Header/Super.tsx
+++ b/components/Header/Super.tsx
@@ -1,5 +1,6 @@
import { IconClear, IconMenu } from "../Shared/SVG/Icons";
import {
+ Logout,
Menu,
MenuToggle,
Super,
@@ -14,6 +15,7 @@ import { NavResponsiveOnly } from "@/components/Nav/Nav.styled";
import { NorthwesternWordmark } from "@/components/Shared/SVG/Northwestern";
import React from "react";
import { UserContext } from "@/context/user-context";
+import useLocalStorage from "@/hooks/useLocalStorage";
const nav = [
{
@@ -33,6 +35,7 @@ const nav = [
export default function HeaderSuper() {
const [isLoaded, setIsLoaded] = React.useState(false);
const [isExpanded, setIsExpanded] = React.useState(false);
+ const [ai, setAI] = useLocalStorage("ai", "false");
React.useEffect(() => {
setIsLoaded(true);
@@ -41,6 +44,11 @@ export default function HeaderSuper() {
const userAuthContext = React.useContext(UserContext);
const handleMenu = () => setIsExpanded(!isExpanded);
+ const handleLogout = () => {
+ if (ai === "true") setAI("false");
+ window.location.href = `${DCAPI_ENDPOINT}/auth/logout`;
+ };
+
return (
@@ -68,16 +76,7 @@ export default function HeaderSuper() {
{userAuthContext?.user?.isLoggedIn && (
<>
{userAuthContext.user.name}
-
- Logout
-
+ Logout
>
)}
diff --git a/components/Hero/Hero.styled.ts b/components/Hero/Hero.styled.ts
index 79666c47..4eb41ff1 100644
--- a/components/Hero/Hero.styled.ts
+++ b/components/Hero/Hero.styled.ts
@@ -45,7 +45,7 @@ const HeroStyled = styled("div", {
width: "100%",
height: "300px",
background:
- "linear-gradient(173deg, $purple 0%, #4E2A84cc 19%, #0000 61.8%)",
+ "linear-gradient(180deg, $purple 0%, #4E2A84cc 19%, #0000 61.8%)",
position: "absolute",
zIndex: "1",
},
@@ -90,8 +90,7 @@ const HeroStyled = styled("div", {
display: "flex",
width: "100%",
height: "100%",
- background:
- "linear-gradient(7deg, #401F68cc 0%, #000a 20%, #0000 61.8%)",
+ background: "linear-gradient(0deg, #000a 0%, #000a 19%, #0000 50%)",
position: "absolute",
zIndex: "1",
bottom: "0",
diff --git a/components/Homepage/Hero.styled.ts b/components/Homepage/Hero.styled.ts
index 94d6b754..3d7e1f67 100644
--- a/components/Homepage/Hero.styled.ts
+++ b/components/Homepage/Hero.styled.ts
@@ -24,11 +24,6 @@ export const HomepageHeroStyled = styled("div", {
".swiper-slide": {
figure: {
- "&::before": {
- background:
- "linear-gradient(7deg, #000a 0%, #000a 20%, #0000 61.8%)",
- },
-
figcaption: {
alignItems: "flex-end",
bottom: "$gr6",
@@ -41,11 +36,6 @@ export const HomepageHeroStyled = styled("div", {
},
},
},
-
- ".swiper-wrapper::before": {
- background:
- "linear-gradient(173deg, $purple 0%, #4E2A84dd 12%, #0000 31%)",
- },
},
},
});
diff --git a/components/Search/GenerativeAI.styled.ts b/components/Search/GenerativeAI.styled.ts
new file mode 100644
index 00000000..226d2578
--- /dev/null
+++ b/components/Search/GenerativeAI.styled.ts
@@ -0,0 +1,44 @@
+import * as Tooltip from "@radix-ui/react-tooltip";
+
+import { styled } from "@/stitches.config";
+
+/* eslint sort-keys: 0 */
+
+const GenerativeAIToggleWrapper = styled("div", {
+ color: "$black50",
+ fontSize: "$gr2",
+ display: "flex",
+ position: "relative",
+ flexDirection: "row",
+ flexWrap: "nowrap",
+ flexShrink: "0",
+ height: "$gr5",
+ alignItems: "center",
+ marginRight: "$gr1",
+
+ "& label": {
+ cursor: "pointer",
+ flexShrink: "0",
+ marginLeft: "3px",
+ marginRight: "4px",
+ },
+
+ "& svg": {
+ position: "relative",
+ padding: "1px 0",
+ height: "$gr3",
+ width: "$gr3",
+ fill: "$black50",
+ },
+});
+
+const TooltipTrigger = styled(Tooltip.Trigger, {
+ background: "transparent",
+ border: "none",
+});
+
+const TooltipContent = styled(Tooltip.Content, {
+ zIndex: 2,
+});
+
+export { GenerativeAIToggleWrapper, TooltipContent, TooltipTrigger };
diff --git a/components/Search/GenerativeAIToggle.test.tsx b/components/Search/GenerativeAIToggle.test.tsx
new file mode 100644
index 00000000..59b311e8
--- /dev/null
+++ b/components/Search/GenerativeAIToggle.test.tsx
@@ -0,0 +1,132 @@
+import {
+ SearchProvider,
+ defaultState as defaultSearchState,
+} from "@/context/search-context";
+import { render, screen } from "@testing-library/react";
+
+import GenerativeAIToggle from "./GenerativeAIToggle";
+import React from "react";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
+import mockRouter from "next-router-mock";
+import userEvent from "@testing-library/user-event";
+
+const defaultUser = {
+ user: {
+ email: "ace@northewestern.edu",
+ isLoggedIn: true,
+ isReadingRoom: false,
+ name: "Ace Frehley",
+ sub: "xyz123",
+ },
+};
+
+const withUserProvider = (
+ Component: React.ReactNode,
+ user: UserContextType = defaultUser,
+) => {
+ return {Component};
+};
+
+const withSearchProvider = (
+ Component: React.ReactNode,
+ initialState = defaultSearchState,
+) => {
+ return (
+ {Component}
+ );
+};
+
+describe("GenerativeAIToggle", () => {
+ beforeEach(() => {
+ mockRouter.setCurrentUrl("/");
+
+ // Note: localStorage mocked in "jest.setup.js"
+ localStorage.clear();
+ });
+
+ it("renders the generative AI toggle UI and toggles state for a logged in user", async () => {
+ const user = userEvent.setup();
+ render(withUserProvider(withSearchProvider()));
+
+ const label = screen.getByLabelText("Use Generative AI");
+ const checkbox = screen.getByRole("checkbox");
+
+ expect(label).toBeInTheDocument();
+ expect(checkbox).toHaveAttribute("data-state", "unchecked");
+
+ await user.click(checkbox);
+ expect(checkbox).toHaveAttribute("data-state", "checked");
+ expect(localStorage.getItem("ai")).toEqual(JSON.stringify("true"));
+ });
+
+ it("renders the generative AI tooltip", () => {
+ render(withSearchProvider());
+ // Target the svg icon itself
+ const tooltip = screen.getByText("Information Circle");
+
+ expect(tooltip).toBeInTheDocument();
+ });
+
+ it("renders a login dialog for a non-logged-in user when generative ai checkbox is checked", async () => {
+ const user = userEvent.setup();
+ const nonLoggedInUser = {
+ user: {
+ ...defaultUser.user,
+ isLoggedIn: false,
+ },
+ };
+
+ render(
+ withUserProvider(
+ withSearchProvider(),
+ nonLoggedInUser,
+ ),
+ );
+
+ const checkbox = await screen.findByRole("checkbox");
+ await user.click(checkbox);
+
+ const generativeAIDialog = await screen.findByText(
+ "You must be logged in with a Northwestern NetID to use the Generative AI search feature.",
+ );
+
+ expect(generativeAIDialog).toBeInTheDocument();
+ });
+
+ it("renders a toggled generative ai state when localStorage variable is set and user is logged in", () => {
+ const activeSearchState = {
+ ...defaultSearchState,
+ };
+
+ localStorage.setItem("ai", JSON.stringify("true"));
+
+ mockRouter.setCurrentUrl("/search");
+ render(
+ withUserProvider(
+ withSearchProvider(, activeSearchState),
+ ),
+ );
+
+ const checkbox = screen.getByRole("checkbox");
+ expect(checkbox).toHaveAttribute("data-state", "checked");
+ });
+
+ it("updates localStorage ai variable when generative AI checkbox is clicked", async () => {
+ const user = userEvent.setup();
+
+ mockRouter.setCurrentUrl("/");
+
+ localStorage.setItem("ai", JSON.stringify("false"));
+
+ render(
+ withUserProvider(
+ withSearchProvider(, defaultSearchState),
+ ),
+ );
+
+ await user.click(screen.getByRole("checkbox"));
+
+ expect(localStorage.getItem("ai")).toEqual(JSON.stringify("true"));
+ });
+});
diff --git a/components/Search/GenerativeAIToggle.tsx b/components/Search/GenerativeAIToggle.tsx
new file mode 100644
index 00000000..f460ff99
--- /dev/null
+++ b/components/Search/GenerativeAIToggle.tsx
@@ -0,0 +1,72 @@
+import * as Tooltip from "@radix-ui/react-tooltip";
+
+import {
+ AI_DISCLAIMER,
+ AI_LOGIN_ALERT,
+ AI_TOGGLE_LABEL,
+} from "@/lib/constants/common";
+import {
+ CheckboxIndicator,
+ CheckboxRoot as CheckboxRootStyled,
+} from "@/components/Shared/Checkbox.styled";
+import {
+ GenerativeAIToggleWrapper,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/Search/GenerativeAI.styled";
+import { TooltipArrow, TooltipBody } from "../Shared/Tooltip.styled";
+
+import { IconCheck } from "@/components/Shared/SVG/Icons";
+import { IconInfo } from "@/components/Shared/SVG/Icons";
+import React from "react";
+import SharedAlertDialog from "../Shared/AlertDialog";
+import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
+
+function GenerativeAITooltip() {
+ return (
+
+
+
+
+
+
+
+
+ {AI_DISCLAIMER}
+
+
+
+
+ );
+}
+
+export default function GenerativeAIToggle() {
+ const { closeDialog, dialog, isChecked, handleCheckChange, handleLogin } =
+ useGenerativeAISearchToggle();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {AI_LOGIN_ALERT}
+
+ >
+ );
+}
diff --git a/components/Search/JumpTo.tsx b/components/Search/JumpTo.tsx
index 3fc9090a..6455ad45 100644
--- a/components/Search/JumpTo.tsx
+++ b/components/Search/JumpTo.tsx
@@ -1,4 +1,3 @@
-import { Input, SearchStyled } from "./Search.styled";
import React, {
ChangeEvent,
FocusEvent,
@@ -9,6 +8,7 @@ import React, {
import { IconSearch } from "@/components/Shared/SVG/Icons";
import SearchJumpToList from "@/components/Search/JumpToList";
+import { SearchStyled } from "./Search.styled";
import Swiper from "swiper";
interface SearchProps {
@@ -16,7 +16,7 @@ interface SearchProps {
}
const SearchJumpTo: React.FC = ({ isSearchActive }) => {
- const search = useRef(null);
+ const search = useRef(null);
const formRef = useRef(null);
const [searchValue, setSearchValue] = useState("");
const [searchFocus, setSearchFocus] = useState(false);
@@ -75,7 +75,7 @@ const SearchJumpTo: React.FC = ({ isSearchActive }) => {
}
};
- const handleSearchChange = (e: ChangeEvent) => {
+ const handleSearchChange = (e: ChangeEvent) => {
const value = e.target.value;
setSearchValue(value);
setShowJumpTo(Boolean(value));
@@ -91,7 +91,8 @@ const SearchJumpTo: React.FC = ({ isSearchActive }) => {
return (
- = ({
+ tabs,
+ activeTab,
+ renderTabList,
+}) => {
+ const {
+ searchState: { searchFixed },
+ } = useSearchState();
+
+ const optionsRef = useRef(null);
+
+ return (
+
+
+
+ {renderTabList && {tabs}}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SearchOptions;
diff --git a/components/Search/Pagination.styled.ts b/components/Search/Pagination.styled.ts
index da0f469b..e537e594 100644
--- a/components/Search/Pagination.styled.ts
+++ b/components/Search/Pagination.styled.ts
@@ -51,6 +51,9 @@ const LeftNav = styled("div", {
const NavWrapper = styled("div", {
display: "flex",
+ alignSelf: "flex-end",
+ flexGrow: "1",
+ justifyContent: "flex-end",
"& button": {
fontSize: "$gr3",
diff --git a/components/Search/PaginationAltCounts.test.tsx b/components/Search/PaginationAltCounts.test.tsx
index ba49a874..7be2d431 100644
--- a/components/Search/PaginationAltCounts.test.tsx
+++ b/components/Search/PaginationAltCounts.test.tsx
@@ -1,4 +1,5 @@
import { render, screen } from "@/test-utils";
+
import PaginationAltCounts from "@/components/Search/PaginationAltCounts";
/* eslint sort-keys: 0 */
@@ -71,4 +72,15 @@ describe("PaginationAltCounts component", () => {
expect(screen.getByText(/previous/i)).toBeInTheDocument();
expect(screen.queryByText(/next/i)).toBeNull();
});
+
+ it("renders a pagination navigation without results for AI responses", () => {
+ render(
+ ,
+ );
+
+ const results = screen.queryByTestId("results");
+ expect(screen.getByTestId("pagination-alt-counts")).not.toContainElement(
+ results,
+ );
+ });
});
diff --git a/components/Search/PaginationAltCounts.tsx b/components/Search/PaginationAltCounts.tsx
index 62d67360..d2174451 100644
--- a/components/Search/PaginationAltCounts.tsx
+++ b/components/Search/PaginationAltCounts.tsx
@@ -3,6 +3,7 @@ import {
PaginationStyled,
Results,
} from "@/components/Search/Pagination.styled";
+
import { Button } from "@nulib/design-system";
import { Pagination as PaginationShape } from "@/types/api/response";
import { pluralize } from "@/lib/utils/count-helpers";
@@ -10,9 +11,13 @@ import { useRouter } from "next/router";
interface PaginationProps {
pagination: PaginationShape;
+ showResultCounts?: boolean;
}
-const PaginationAltCounts: React.FC = ({ pagination }) => {
+const PaginationAltCounts: React.FC = ({
+ pagination,
+ showResultCounts = true,
+}) => {
const { current_page, limit, total_hits, total_pages } = pagination;
const router = useRouter();
@@ -37,10 +42,12 @@ const PaginationAltCounts: React.FC = ({ pagination }) => {
css={{ borderTopWidth: "1px", paddingTop: "$gr2" }}
data-testid="pagination-alt-counts"
>
-
- Showing {startCount} to {endCount} of{" "}
- {pluralize("result", total_hits)}
-
+ {showResultCounts && (
+
+ Showing {startCount} to {endCount} of{" "}
+ {pluralize("result", total_hits)}
+
+ )}
{current_page > 2 && (
diff --git a/components/Search/PublicOnlyWorks.tsx b/components/Search/PublicOnlyWorks.tsx
index 16868582..f3863e2b 100644
--- a/components/Search/PublicOnlyWorks.tsx
+++ b/components/Search/PublicOnlyWorks.tsx
@@ -5,6 +5,7 @@ import {
StyledToggle,
Wrapper,
} from "@/components/Shared/Switch.styled";
+
import React from "react";
import useQueryParams from "@/hooks/useQueryParams";
import { useRouter } from "next/router";
@@ -32,7 +33,7 @@ const SearchPublicOnlyWorks = () => {
= ({
+ data,
+ error,
+ loading,
+}) => {
+ const { isChecked: isAI } = useGenerativeAISearchToggle();
+
+ const totalResults = data?.pagination?.total_hits;
+
+ return (
+
+ {loading && <>>}
+ {error && {error}
}
+ {data && (
+ <>
+ {!isAI &&
+ (totalResults ? (
+
+ {pluralize("result", totalResults)}
+
+ ) : (
+
+ Your search did not match any results. Please
+ try broadening your search terms or adjusting your filters.
+
+ ))}
+
+ {totalResults ? (
+
+ ) : (
+ <>>
+ )}
+ >
+ )}
+
+ );
+};
+
+export default SearchResults;
diff --git a/components/Search/Search.styled.ts b/components/Search/Search.styled.ts
index a7f5e0c6..7685bef4 100644
--- a/components/Search/Search.styled.ts
+++ b/components/Search/Search.styled.ts
@@ -7,25 +7,50 @@ const SearchStyled = styled("form", {
display: "flex",
flexShrink: "0",
flexGrow: "1",
- backgroundColor: "$white",
- height: "$gr5",
- marginRight: "$gr5",
- boxShadow: "3px -3px 19px #fff1",
+ marginRight: "$gr4",
transition: "$dcAll",
+ borderRadius: "3px",
+ flexWrap: "wrap",
+
+ variants: {
+ isFocused: {
+ true: {
+ backgroundColor: "$white !important",
+ boxShadow: "3px 3px 11px #0001",
+ outline: "2px solid $purple60",
+ },
+ false: {
+ backgroundColor: "#f0f0f0",
+ boxShadow: "none",
+ outline: "2px solid transparent",
+ },
+ },
+ },
+
+ "> div": {
+ display: "flex",
+ justifyContent: "flex-end",
+
+ "&:first-child": {
+ flexGrow: "1",
+ },
+ },
"@sm": {
width: "100%",
marginRight: "0",
+ flexDirection: "column",
},
"@lg": {
marginRight: "$gr3",
},
- svg: {
+ "> svg": {
position: "absolute",
display: "flex",
left: "0",
+ top: "3px",
height: "$gr5",
width: "$gr5",
justifyContent: "center",
@@ -34,69 +59,34 @@ const SearchStyled = styled("form", {
border: "none",
backgroundColor: "transparent",
zIndex: "0",
- fill: "$black80",
- padding: "$gr2",
- },
-});
-
-const Input = styled("input", {
- position: "relative",
- display: "flex",
- width: "100%",
- border: "none",
- backgroundColor: "transparent",
- padding: "1px $gr3 0 $gr5",
- fontSize: "$gr3",
- zIndex: "1",
- fontFamily: "$northwesternSansRegular",
- whiteSpace: "nowrap",
-
- "&::placeholder": {
- overflow: "hidden",
- color: "$black80",
- textOverflow: "ellipsis",
- marginRight: "$gr5",
+ fill: "$black50",
+ padding: "15px",
},
});
const Button = styled("button", {
display: "flex",
- justifyContent: "center",
- alignItems: "center",
border: "none",
- backgroundColor: "$purple120",
- padding: "0 $gr3 ",
+ backgroundColor: "$purple",
+ alignItems: "center",
+ padding: "0 $gr2",
+ margin: "7px",
+ height: "38px",
color: "$white",
- fontSize: "$gr4",
+ fontSize: "$gr3",
+ borderRadius: "3px",
fontFamily: "$northwesternSansRegular",
cursor: "pointer",
textRendering: "optimizeLegibility",
-});
-
-const Clear = styled("button", {
- position: "absolute",
- display: "flex",
- right: "5rem",
- height: "$gr5",
- width: "calc($gr5 + $gr2)",
- justifyContent: "center",
- textAlign: "center",
- alignItems: "center",
- cursor: "pointer",
- border: "none",
- background: "linear-gradient(90deg, #fff0 0, #fff 38.2%)",
- zIndex: "1",
- fill: "$black80",
-
- "&:focus, &:hover": {
- fill: "$purple30",
- },
+ gap: "$gr1",
+ position: "relative",
- svg: {
- fill: "inherit",
- padding: "$gr2",
- marginLeft: "$gr2",
- transition: "$dcAll",
+ "> svg": {
+ width: "$gr3",
+ height: "$gr3",
+ marginTop: "-2px",
+ fontFamily: "Times !important",
+ color: "$purple60",
},
});
@@ -141,12 +131,15 @@ const ResultsWrapper = styled("div", {
minHeight: "80vh",
});
+const StyledResponseWrapper = styled("div", {
+ padding: "0 0 $gr6",
+});
+
export {
Button,
- Clear,
- Input,
NoResultsMessage,
ResultsMessage,
ResultsWrapper,
SearchStyled,
+ StyledResponseWrapper,
};
diff --git a/components/Search/Search.test.tsx b/components/Search/Search.test.tsx
index 7941a346..d0e76c8e 100644
--- a/components/Search/Search.test.tsx
+++ b/components/Search/Search.test.tsx
@@ -1,5 +1,8 @@
import { render, screen } from "@/test-utils";
+
import Search from "./Search";
+import { UserContext } from "@/context/user-context";
+import { UserContext as UserContextType } from "@/types/context/user";
import mockRouter from "next-router-mock";
import { renderHook } from "@testing-library/react";
import { useRouter } from "next/router";
@@ -7,7 +10,28 @@ import userEvent from "@testing-library/user-event";
const mockIsSearchActive = jest.fn();
+const defaultUser = {
+ user: {
+ email: "ace@northewestern.edu",
+ isLoggedIn: true,
+ isReadingRoom: false,
+ name: "Ace Frehley",
+ sub: "xyz123",
+ },
+};
+
+const withUserProvider = (
+ Component: React.ReactNode,
+ user: UserContextType = defaultUser,
+) => {
+ return {Component};
+};
+
describe("Search component", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
it("renders the search ui component", () => {
render( ({})} />);
const wrapper = screen.getByTestId("search-ui-component");
@@ -61,4 +85,28 @@ describe("Search component", () => {
asPath: "/search?q=foo&subject=baz",
});
});
+
+ it("renders the generative ai toggle component", async () => {
+ render();
+ expect(screen.getByTestId("generative-ai-toggle")).toBeInTheDocument();
+ });
+
+ it("renders standard placeholder text for non AI search", () => {
+ render();
+ const input = screen.getByPlaceholderText(
+ "Search by keyword or phrase, ex: Berkeley Music Festival",
+ );
+ expect(input).toBeInTheDocument();
+ });
+
+ it("renders generative AI placeholder text when AI search is active", () => {
+ localStorage.setItem("ai", JSON.stringify("true"));
+
+ render(withUserProvider());
+
+ const input = screen.getByPlaceholderText(
+ "What can I show you from our collections?",
+ );
+ expect(input).toBeInTheDocument();
+ });
});
diff --git a/components/Search/Search.tsx b/components/Search/Search.tsx
index a825dcbc..9acfef13 100644
--- a/components/Search/Search.tsx
+++ b/components/Search/Search.tsx
@@ -1,9 +1,5 @@
-import { Button, Clear, Input, SearchStyled } from "./Search.styled";
-import {
- IconArrowForward,
- IconClear,
- IconSearch,
-} from "@/components/Shared/SVG/Icons";
+import { Button, SearchStyled } from "@/components/Search/Search.styled";
+import { IconArrowForward, IconSearch } from "@/components/Shared/SVG/Icons";
import React, {
ChangeEvent,
FocusEvent,
@@ -13,7 +9,11 @@ import React, {
useState,
} from "react";
-import { ALL_FACETS } from "@/lib/constants/facets-model";
+import GenerativeAIToggle from "./GenerativeAIToggle";
+import SearchTextArea from "@/components/Search/TextArea";
+import { UrlFacets } from "@/types/context/filter-context";
+import { getAllFacetIds } from "@/lib/utils/facet-helpers";
+import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
import useQueryParams from "@/hooks/useQueryParams";
import { useRouter } from "next/router";
@@ -23,61 +23,70 @@ interface SearchProps {
const Search: React.FC = ({ isSearchActive }) => {
const router = useRouter();
- const search = useRef(null);
+ const { urlFacets } = useQueryParams();
+
+ const { isChecked, handleCheckChange } = useGenerativeAISearchToggle();
+
+ const searchRef = useRef(null);
const formRef = useRef(null);
+
+ const [isLoaded, setIsLoaded] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [searchFocus, setSearchFocus] = useState(false);
- const [isLoaded, setIsLoaded] = useState(false);
- const { urlFacets } = useQueryParams();
- const handleSubmit = (e: SyntheticEvent) => {
- e.preventDefault();
+ const handleSubmit = (
+ e?:
+ | SyntheticEvent
+ | React.KeyboardEvent,
+ ) => {
+ if (e) e.preventDefault();
- /* Guard against searching from a page with dynamic route params */
- const facetIds = ALL_FACETS.facets.map((facet) => facet.id);
+ const updatedFacets: UrlFacets = {};
+ const allFacetsIds = getAllFacetIds();
// Account for the "similar" facet (comes from "View All" in sliders)
- facetIds.push("similar");
+ allFacetsIds.push("similar");
- const urlFacetsKeys = Object.keys(urlFacets);
- urlFacetsKeys.forEach((key) => {
- if (!facetIds.includes(key)) {
- delete urlFacets[key];
+ // Guard against searching from a page with dynamic route params
+ Object.keys(urlFacets).forEach((facetKey) => {
+ if (allFacetsIds.includes(facetKey)) {
+ updatedFacets[facetKey] = urlFacets[facetKey];
}
});
router.push({
pathname: "/search",
- query: { q: searchValue, ...urlFacets },
+ query: {
+ q: searchValue,
+ ...updatedFacets,
+ },
});
};
- const handleSearchFocus = (e: FocusEvent) => {
+ const handleSearchFocus = (e: FocusEvent) => {
setSearchFocus(e.type === "focus");
};
- const handleSearchChange = (e: ChangeEvent) => {
+ const handleSearchChange = (e: ChangeEvent) => {
const value = e.target.value;
setSearchValue(value);
};
const clearSearchResults = () => {
setSearchValue("");
- if (search.current) search.current.value = "";
+ if (searchRef.current) searchRef.current.value = "";
router.push({
pathname: "/search",
query: { ...urlFacets },
});
};
- useEffect(() => {
- setIsLoaded(true);
- }, []);
+ useEffect(() => setIsLoaded(true), []);
useEffect(() => {
if (router) {
const { q } = router.query;
- if (q && search.current) search.current.value = q as string;
+ if (q && searchRef.current) searchRef.current.value = q as string;
setSearchValue(q as string);
}
}, [router]);
@@ -91,24 +100,24 @@ const Search: React.FC = ({ isSearchActive }) => {
ref={formRef}
onSubmit={handleSubmit}
data-testid="search-ui-component"
+ isFocused={searchFocus}
>
-
- {searchValue && (
-
-
-
- )}
-
+
+
+
+
{isLoaded && }
);
diff --git a/components/Search/TextArea.styled.ts b/components/Search/TextArea.styled.ts
new file mode 100644
index 00000000..2863bc8c
--- /dev/null
+++ b/components/Search/TextArea.styled.ts
@@ -0,0 +1,79 @@
+import { styled } from "@/stitches.config";
+
+/* eslint sort-keys: 0 */
+
+const Clear = styled("button", {
+ position: "absolute",
+ display: "flex",
+ right: "0",
+ height: "$gr5",
+ width: "$gr5",
+ marginRight: "$gr2",
+ justifyContent: "center",
+ textAlign: "center",
+ alignItems: "center",
+ cursor: "pointer",
+ border: "none",
+ background: "linear-gradient(90deg, #f0f0f000 0, transparent 38.2%)",
+ zIndex: "1",
+ fill: "$black50",
+
+ "&:focus, &:hover": {
+ fill: "$brightRed",
+ },
+
+ svg: {
+ fill: "inherit",
+ padding: "5px",
+ marginLeft: "$gr2",
+ transition: "$dcAll",
+ },
+});
+
+const StyledTextArea = styled("div", {
+ position: "relative",
+ display: "flex",
+ flexGrow: "1",
+
+ variants: {
+ isFocused: {
+ true: {
+ color: "$black",
+ },
+ false: {
+ textarea: {
+ height: "$gr5 !important",
+ color: "$black50",
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ },
+ },
+ },
+ },
+
+ textarea: {
+ background: "transparent",
+ padding: "15px $gr5 10px",
+ border: "none",
+ fontSize: "$gr3",
+ lineHeight: "147%",
+ height: "$gr5",
+ zIndex: "1",
+ fontFamily: "$northwesternSansRegular",
+ resize: "none",
+ overflow: "hidden",
+ width: "100%",
+ outline: "none",
+ transition: "$dcAll",
+ boxSizing: "border-box",
+
+ "&::placeholder": {
+ overflow: "hidden",
+ color: "$black80",
+ textOverflow: "ellipsis",
+ },
+ },
+});
+
+export { Clear, StyledTextArea };
diff --git a/components/Search/TextArea.tsx b/components/Search/TextArea.tsx
new file mode 100644
index 00000000..290ab5b7
--- /dev/null
+++ b/components/Search/TextArea.tsx
@@ -0,0 +1,93 @@
+import { Clear, StyledTextArea } from "@/components/Search/TextArea.styled";
+import React, {
+ ChangeEvent,
+ FocusEvent,
+ KeyboardEvent,
+ forwardRef,
+ useEffect,
+ useRef,
+} from "react";
+
+import { IconClear } from "@/components/Shared/SVG/Icons";
+
+interface SearchTextAreaProps {
+ isAi: boolean;
+ isFocused: boolean;
+ searchValue: string;
+ handleSearchChange: (e: ChangeEvent) => void;
+ handleSearchFocus: (e: FocusEvent) => void;
+ handleSubmit: (e: KeyboardEvent) => void;
+ clearSearchResults: () => void;
+}
+
+const SearchTextArea = forwardRef(
+ (
+ {
+ isAi,
+ isFocused,
+ searchValue,
+ handleSearchChange,
+ handleSearchFocus,
+ handleSubmit,
+ clearSearchResults,
+ },
+ textareaRef,
+ ) => {
+ /**
+ * Resize the textarea to fit its content
+ */
+ useEffect(() => {
+ // @ts-ignore
+ const textarea = textareaRef?.current;
+ if (textarea) {
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ }
+ }, [searchValue, isFocused]);
+
+ const handleChange = (e: ChangeEvent) => {
+ handleSearchChange(e);
+
+ // @ts-ignore
+ const textarea = textareaRef?.current;
+ if (textarea) {
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ }
+ };
+
+ const placeholderText = isAi
+ ? "What can I show you from our collections?"
+ : "Search by keyword or phrase, ex: Berkeley Music Festival";
+
+ return (
+
+
+ );
+ },
+);
+
+SearchTextArea.displayName = "SearchTextArea";
+
+export default SearchTextArea;
diff --git a/components/Shared/AlertDialog.styled.ts b/components/Shared/AlertDialog.styled.ts
new file mode 100644
index 00000000..941b2808
--- /dev/null
+++ b/components/Shared/AlertDialog.styled.ts
@@ -0,0 +1,61 @@
+import * as AlertDialog from "@radix-ui/react-alert-dialog";
+
+import { styled } from "@/stitches.config";
+
+/* eslint sort-keys: 0 */
+
+const AlertDialogOverlay = styled(AlertDialog.Overlay, {
+ background: "rgba(0 0 0 / 0.382)",
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ display: "grid",
+ placeItems: "center",
+ overflowY: "auto",
+ zIndex: "1",
+});
+
+const AlertDialogContent = styled(AlertDialog.Content, {
+ backgroundColor: "white",
+ borderRadius: 6,
+ boxShadow:
+ "hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
+ position: "fixed",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ width: "90vw",
+ maxWidth: "500px",
+ maxHeight: "85vh",
+ padding: 25,
+ zIndex: "2",
+
+ "&:focus": { outline: "none" },
+});
+
+const AlertDialogTitle = styled(AlertDialog.Title, {
+ fontSize: "$gr4",
+ lineHeight: "1.5rem",
+ padding: "0",
+ margin: "0",
+ color: "$black50",
+ fontWeight: "400",
+});
+
+const AlertDialogButtonRow = styled("div", {
+ display: "flex",
+ justifyContent: "flex-end",
+
+ "& > *:not(:last-child)": {
+ marginRight: "$gr3",
+ },
+});
+
+export {
+ AlertDialogButtonRow,
+ AlertDialogContent,
+ AlertDialogOverlay,
+ AlertDialogTitle,
+};
diff --git a/components/Shared/AlertDialog.test.tsx b/components/Shared/AlertDialog.test.tsx
new file mode 100644
index 00000000..ac18aca0
--- /dev/null
+++ b/components/Shared/AlertDialog.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+
+import React from "react";
+import SharedAlertDialog from "./AlertDialog";
+
+const defaultProps = {
+ action: { label: "Login", onClick: jest.fn() },
+ cancel: { label: "Cancel", onClick: jest.fn() },
+ isOpen: true,
+ title: "Title",
+};
+
+describe("SharedAlertDialog", () => {
+ it("renders the primary components", () => {
+ render(Content);
+
+ expect(screen.getByText("Content")).toBeInTheDocument();
+ expect(screen.getByRole("heading")).toHaveTextContent(defaultProps.title);
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Login" })).toBeInTheDocument();
+ });
+
+ it("calls the cancel and action callback functions", () => {
+ render(Content);
+
+ screen.getByRole("button", { name: "Cancel" }).click();
+ expect(defaultProps.cancel.onClick).toHaveBeenCalled();
+
+ screen.getByRole("button", { name: "Login" }).click();
+ expect(defaultProps.action.onClick).toHaveBeenCalled();
+ });
+});
diff --git a/components/Shared/AlertDialog.tsx b/components/Shared/AlertDialog.tsx
new file mode 100644
index 00000000..a92f4e58
--- /dev/null
+++ b/components/Shared/AlertDialog.tsx
@@ -0,0 +1,61 @@
+import * as AlertDialog from "@radix-ui/react-alert-dialog";
+
+import {
+ AlertDialogButtonRow,
+ AlertDialogContent,
+ AlertDialogOverlay,
+ AlertDialogTitle,
+} from "@/components/Shared/AlertDialog.styled";
+
+import { Button } from "@nulib/design-system";
+import { ReactNode } from "react";
+
+type DialogButton = {
+ label: string;
+ onClick: () => void;
+};
+
+interface SharedAlertDialogProps {
+ children: ReactNode;
+ action: DialogButton;
+ cancel?: DialogButton;
+ isOpen: boolean;
+ size?: "small" | "large";
+ title?: string;
+}
+
+export default function SharedAlertDialog({
+ children,
+ action,
+ cancel,
+ isOpen,
+ title,
+}: SharedAlertDialogProps) {
+ const cancelLabel = cancel?.label || "Cancel";
+
+ return (
+
+
+
+
+ {title && {title}}
+
+ {children}
+
+ {cancel && (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/Shared/BouncingLoader.tsx b/components/Shared/BouncingLoader.tsx
new file mode 100644
index 00000000..4859065b
--- /dev/null
+++ b/components/Shared/BouncingLoader.tsx
@@ -0,0 +1,47 @@
+import { keyframes, styled } from "@/stitches.config";
+
+import React from "react";
+
+const BouncingLoader = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+/* eslint sort-keys: 0 */
+
+const bouncingLoader = keyframes({
+ to: {
+ backgroundColor: "$brightBlueB",
+ transform: "translateY(-13px)",
+ },
+});
+
+const StyledBouncingLoader = styled("div", {
+ display: "flex",
+ margin: "$gr2 auto",
+
+ "& > div": {
+ width: "$gr2",
+ height: "$gr2",
+ margin: "$gr1 $gr1",
+ borderRadius: "50%",
+ backgroundColor: "$purple",
+ opacity: 1,
+ animation: `${bouncingLoader} 0.6s infinite alternate`,
+
+ "&:nth-child(2)": {
+ animationDelay: "0.2s",
+ },
+
+ "&:nth-child(3)": {
+ animationDelay: "0.4s",
+ },
+ },
+});
+
+export default BouncingLoader;
diff --git a/components/Shared/Checkbox.styled.tsx b/components/Shared/Checkbox.styled.tsx
new file mode 100644
index 00000000..52352610
--- /dev/null
+++ b/components/Shared/Checkbox.styled.tsx
@@ -0,0 +1,39 @@
+import * as Checkbox from "@radix-ui/react-checkbox";
+
+import { styled } from "@/stitches.config";
+
+/* eslint sort-keys: 0 */
+
+export const CheckboxRoot = styled(Checkbox.Root, {
+ display: "flex",
+ marginRight: "$gr1",
+ width: "$gr3",
+ height: "$gr3",
+ flexGrow: "0",
+ flexShrink: "0",
+ backgroundColor: "$white",
+ border: "1px solid $black10",
+ padding: "0",
+ cursor: "pointer",
+ borderRadius: "3px",
+ color: "$white",
+
+ svg: {
+ padding: "2px",
+ },
+
+ "&:hover, &:focus": {
+ borderColor: "$black20",
+ boxShadow: "2px 2px 5px #0002",
+ color: "$white",
+ },
+});
+
+export const CheckboxIndicator = styled(Checkbox.Indicator, {
+ display: "flex",
+ width: "$gr3",
+ height: "$gr3",
+ backgroundColor: "$purple",
+ margin: "-1px 0 0 -1px",
+ borderRadius: "3px",
+});
diff --git a/components/Shared/Dialog.styled.ts b/components/Shared/Dialog.styled.ts
index e389f8c3..f10e4c11 100644
--- a/components/Shared/Dialog.styled.ts
+++ b/components/Shared/Dialog.styled.ts
@@ -1,4 +1,5 @@
import * as Dialog from "@radix-ui/react-dialog";
+
import { styled } from "@/stitches.config";
/* eslint sort-keys: 0 */
@@ -113,6 +114,17 @@ const DialogContent = styled(Dialog.Content, {
variants: {
size: {
+ small: {
+ top: "12rem",
+ left: "12rem",
+ width: "calc(100vw - 24rem)",
+ height: "calc(100vh - 24rem)",
+ minHeight: "300px",
+
+ [`& ${DialogBody}`]: {
+ display: "flex",
+ },
+ },
large: {
top: "5rem",
left: "5rem",
diff --git a/components/Shared/Dialog.tsx b/components/Shared/Dialog.tsx
index 29f26fd6..809563ce 100644
--- a/components/Shared/Dialog.tsx
+++ b/components/Shared/Dialog.tsx
@@ -1,4 +1,5 @@
import * as Dialog from "@radix-ui/react-dialog";
+
import {
DialogBody,
DialogClose,
@@ -6,6 +7,7 @@ import {
DialogHeader,
DialogOverlay,
} from "@/components/Shared/Dialog.styled";
+
import { IconClear } from "@/components/Shared/SVG/Icons";
import { ReactNode } from "react";
@@ -13,14 +15,14 @@ interface SharedDialogProps {
children: ReactNode;
handleCloseClick: () => void;
isOpen: boolean;
- large?: boolean;
+ size?: "small" | "large";
title: string;
}
const SharedDialog: React.FC = ({
children,
handleCloseClick,
- large,
+ size,
isOpen,
title,
}) => {
@@ -30,7 +32,7 @@ const SharedDialog: React.FC = ({
{title}
diff --git a/components/Shared/Loader.styled.ts b/components/Shared/Loader.styled.ts
index b901cee5..5e705dcc 100644
--- a/components/Shared/Loader.styled.ts
+++ b/components/Shared/Loader.styled.ts
@@ -8,14 +8,25 @@ const rotation = keyframes({
});
const SpinLoader = styled("span", {
- width: "48px",
- height: "48px",
- border: "5px solid $black10",
+ border: "3px solid $black10",
borderBottomColor: "transparent",
borderRadius: "50%",
display: "inline-block",
boxSizing: "border-box",
animation: `${rotation} 1s linear infinite`,
+
+ variants: {
+ size: {
+ small: {
+ width: "24px",
+ height: "24px",
+ },
+ default: {
+ width: "48px",
+ height: "48px",
+ },
+ },
+ },
});
export { SpinLoader };
diff --git a/components/Shared/SVG/Icons.tsx b/components/Shared/SVG/Icons.tsx
index 3f584abd..7e5344b6 100644
--- a/components/Shared/SVG/Icons.tsx
+++ b/components/Shared/SVG/Icons.tsx
@@ -182,6 +182,33 @@ const IconSocialTwitter: React.FC = () => (
);
+const IconSparkles: React.FC = () => (
+
+);
+
+const IconThumbsDown: React.FC = () => (
+
+);
+
+const IconThumbsUp: React.FC = () => (
+
+);
+
const IconVideo: React.FC = () => (