Skip to content

Commit 7d5e9f1

Browse files
Feat: New AI UI Entry (#37)
* Feat: New AI UI Entry * Fix mobile version * Fix formatting * Use refs
1 parent 984b5de commit 7d5e9f1

File tree

7 files changed

+400
-9
lines changed

7 files changed

+400
-9
lines changed

app/components/ai-agent.tsx

Lines changed: 298 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,66 @@
11
"use client";
22

3-
import { createChat } from "@n8n/chat";
3+
import { Button } from "@/components/ui/button";
44
import config from "@/lib/config";
5-
import { useEffect } from "react";
5+
import { createChat } from "@n8n/chat";
6+
import { useEffect, useRef, useState } from "react";
67

78
import "@n8n/chat/style.css";
89
import "../styles/ai-agent.css";
10+
import { LoaderCircle, SparklesIcon } from "lucide-react";
11+
import Image from "next/image";
12+
import { cn } from "@/lib/utils";
13+
import useMedia from "@/hooks/use-media";
14+
15+
const DEFAULT_MESSAGES = [
16+
{ id: 1, text: "What services does Hyperjump offer?" },
17+
{ id: 2, text: "Show me examples of past projects" },
18+
{ id: 3, text: "Schedule a free consultation" }
19+
];
20+
21+
export default function AIAgent() {
22+
const isDesktop = useMedia("(min-width: 992px)");
23+
const [text, setText] = useState<string>("");
24+
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
25+
const [isChatOpen, setIsChatOpen] = useState<boolean>(true);
26+
27+
// Refs to store DOM elements
28+
const chatDivRef = useRef<HTMLElement | null>(null);
29+
const chatWindowRef = useRef<HTMLElement | null>(null);
30+
const chatFABRef = useRef<HTMLElement | null>(null);
31+
32+
// Helper function to trigger click event
33+
const triggerClick = (element: Element) => {
34+
const clickEvent = new MouseEvent("click", {
35+
bubbles: true,
36+
cancelable: true,
37+
view: window
38+
});
39+
element.dispatchEvent(clickEvent);
40+
};
41+
42+
// Helper function to initialize chat elements
43+
const initializeChatElements = () => {
44+
const chatDiv = document.querySelector("#n8n-chat");
45+
if (!chatDiv) return;
46+
47+
chatDivRef.current = chatDiv as HTMLElement;
48+
chatWindowRef.current = chatDiv.querySelector(
49+
".chat-window-wrapper"
50+
) as HTMLElement;
51+
chatFABRef.current = chatDiv.querySelector(
52+
".chat-window-toggle"
53+
) as HTMLElement;
954

10-
export const AIAgent = () => {
55+
if (chatWindowRef.current) {
56+
chatWindowRef.current.classList.toggle("chat-window-minimized");
57+
}
58+
};
59+
60+
// Effect to create the chat widget
1161
useEffect(() => {
1262
if (config.AI_AGENT_URL) {
13-
createChat({
63+
const chat = createChat({
1464
webhookUrl: config.AI_AGENT_URL,
1565
initialMessages: [
1666
`Hi there! 👋`,
@@ -30,8 +80,249 @@ export const AIAgent = () => {
3080
}
3181
}
3282
});
83+
initializeChatElements();
84+
85+
// Create chat controls
86+
const chatHeader = chatDivRef.current?.querySelector(".chat-header");
87+
if (chatHeader) {
88+
const createButton = (
89+
title: string,
90+
icon: string,
91+
marginRight: string,
92+
onClick: () => void
93+
) => {
94+
const button = document.createElement("button");
95+
button.classList.add(
96+
"absolute",
97+
"flex",
98+
"items-center",
99+
"justify-center",
100+
"right-0",
101+
"top-0",
102+
"text-white",
103+
"border",
104+
"border-white",
105+
"mt-4",
106+
marginRight,
107+
"h-7",
108+
"w-7",
109+
"rounded-full",
110+
"bg-transparent",
111+
"hover:bg-gray-100",
112+
"hover:text-black",
113+
"transition-all",
114+
"duration-300"
115+
);
116+
button.type = "button";
117+
button.title = title;
118+
button.innerHTML = icon;
119+
button.onclick = onClick;
120+
return button;
121+
};
122+
123+
// Add minimize button
124+
chatHeader.appendChild(
125+
createButton(
126+
"Minimize",
127+
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>`,
128+
"mr-4",
129+
() => {
130+
if (chatFABRef.current) {
131+
triggerClick(chatFABRef.current);
132+
setIsChatOpen(false);
133+
if (!isDesktop) {
134+
chatFABRef.current.setAttribute("style", "display:flex;");
135+
} else if (chatWindowRef.current) {
136+
chatWindowRef.current.classList.add("chat-window-right");
137+
chatWindowRef.current.classList.toggle(
138+
"chat-window-minimized"
139+
);
140+
}
141+
}
142+
}
143+
)
144+
);
145+
146+
// Add maximize button for desktop
147+
if (isDesktop) {
148+
chatHeader.appendChild(
149+
createButton(
150+
"Full screen",
151+
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize-2"><path d="M15 3h6v6"></path><path d="m21 3-7 7"></path><path d="m3 21 7-7"></path><path d="M9 21H3v-6"></path></svg>`,
152+
"mr-12",
153+
() => {
154+
if (chatWindowRef.current) {
155+
if (chatWindowRef.current) {
156+
if (
157+
chatWindowRef.current.classList.contains(
158+
"chat-window-right"
159+
)
160+
) {
161+
chatWindowRef.current.classList.remove(
162+
"chat-window-right"
163+
);
164+
chatWindowRef.current.classList.add(
165+
"chat-window-centered"
166+
);
167+
} else {
168+
chatWindowRef.current.classList.remove(
169+
"chat-window-centered"
170+
);
171+
chatWindowRef.current.classList.add("chat-window-right");
172+
}
173+
}
174+
}
175+
}
176+
)
177+
);
178+
}
179+
}
180+
181+
return () => {
182+
chat.unmount();
183+
};
184+
}
185+
}, [isDesktop]);
186+
187+
// Effect to handle mobile FAB
188+
useEffect(() => {
189+
if (!isDesktop && chatFABRef.current) {
190+
chatFABRef.current.addEventListener("click", () => {
191+
chatFABRef.current?.setAttribute("style", "display:none;");
192+
});
33193
}
34-
}, []);
35194

36-
return <></>;
37-
};
195+
return () => {
196+
if (!isDesktop && chatFABRef.current) {
197+
chatFABRef.current.removeEventListener("click", () => {
198+
chatFABRef.current?.setAttribute("style", "display:block;");
199+
});
200+
}
201+
};
202+
}, [isDesktop]);
203+
204+
const handleSubmit = async (text: string) => {
205+
if (!text.length || !chatDivRef.current) return;
206+
207+
setIsSubmitted(true);
208+
const textarea = chatDivRef.current.querySelector("textarea");
209+
if (!textarea) return;
210+
211+
textarea.value = text;
212+
textarea.dispatchEvent(new Event("input", { bubbles: true }));
213+
214+
const waitForSendButton = setInterval(() => {
215+
const sendButton = chatDivRef.current?.querySelector(
216+
".chat-input-send-button"
217+
);
218+
if (sendButton && !sendButton.hasAttribute("disabled")) {
219+
clearInterval(waitForSendButton);
220+
triggerClick(sendButton);
221+
222+
if (chatWindowRef.current) {
223+
chatWindowRef.current.classList.toggle("chat-window-minimized");
224+
chatWindowRef.current.classList.toggle("chat-window-centered");
225+
}
226+
227+
if (chatFABRef.current) {
228+
triggerClick(chatFABRef.current);
229+
textarea.value = "";
230+
setText("");
231+
setIsSubmitted(true);
232+
setIsChatOpen(true);
233+
}
234+
}
235+
}, 100);
236+
237+
setTimeout(() => clearInterval(waitForSendButton), 5000);
238+
};
239+
240+
return (
241+
<>
242+
<div
243+
className={cn(
244+
"animate-fade-in-up fixed bottom-0 z-50 mb-8 hidden w-full items-center px-4 transition-all",
245+
isSubmitted ? "hidden" : "lg:flex"
246+
)}>
247+
<div className="mx-auto flex w-full max-w-4xl flex-col gap-2 rounded-xl bg-[url('/images/ai-agent.png')] bg-contain bg-center p-4 shadow-xl">
248+
<div className="flex flex-row items-center gap-2">
249+
<div className="relative flex w-full items-center">
250+
<SparklesIcon
251+
strokeWidth={1.5}
252+
className="absolute left-0 z-10 ml-4 h-6 w-6 text-[#3276F5]"
253+
/>
254+
<input
255+
type="text"
256+
className="z-0 h-[52px] w-full max-w-7xl rounded-lg bg-white p-2 pr-12 pl-12 text-gray-800 placeholder:text-gray-400"
257+
value={text}
258+
onChange={({ target }) => setText(target.value)}
259+
aria-describedby="Ask me about services, success stories, or your challenges"
260+
placeholder="Ask me about services, success stories, or your challenges"
261+
/>
262+
<Button
263+
id="desktop-ai-submit"
264+
type="button"
265+
className="absolute right-0 z-10 mr-4 ml-4 h-7 w-7 rounded-full bg-[#3276F5] p-2 hover:cursor-pointer hover:bg-[#3276F5DD]"
266+
onClick={() => handleSubmit(text)}
267+
disabled={isSubmitted}>
268+
<div className="flex items-center justify-center">
269+
{isSubmitted ? (
270+
<LoaderCircle className="h-4 w-4 animate-spin" />
271+
) : (
272+
<Image
273+
alt="Send message to AI"
274+
src="/icons/ai-agent-button.svg"
275+
width={16}
276+
height={16}
277+
/>
278+
)}
279+
</div>
280+
</Button>
281+
</div>
282+
</div>
283+
<div className="flex shrink-0 flex-row gap-2 overflow-auto">
284+
{DEFAULT_MESSAGES.map(({ text, id }) => (
285+
<Button
286+
key={id}
287+
className="rounded-md border border-white bg-transparent hover:cursor-pointer"
288+
onClick={() => setText(text)}>
289+
{text}
290+
</Button>
291+
))}
292+
</div>
293+
</div>
294+
</div>
295+
<Button
296+
onClick={() => {
297+
const chatFAB = document.querySelector(
298+
"#n8n-chat .chat-window-toggle"
299+
);
300+
if (chatFAB) {
301+
const clickEvent = new MouseEvent("click", {
302+
bubbles: true,
303+
cancelable: true,
304+
view: window
305+
});
306+
chatFAB.dispatchEvent(clickEvent);
307+
308+
// Open the n8n chat window
309+
const chatDiv = document.querySelector("#n8n-chat");
310+
if (chatDiv) {
311+
const chatWindow = chatDiv.querySelector(".chat-window-wrapper");
312+
if (chatWindow) {
313+
chatWindow.classList.toggle("chat-window-minimized");
314+
}
315+
}
316+
317+
setIsChatOpen(true);
318+
}
319+
}}
320+
className={cn(
321+
isChatOpen ? "hidden" : "lg:flex",
322+
"fixed right-0 bottom-0 z-50 mr-8 mb-4 hidden rounded-full bg-[#3276F5] p-2 px-4 font-bold hover:cursor-pointer hover:bg-[#3276F5DD]"
323+
)}>
324+
<div className="flex items-center justify-center">Ask HyperBot</div>
325+
</Button>
326+
</>
327+
);
328+
}

app/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { cn } from "@/app/utils/tailwind";
66
import Console from "@/app/components/console";
77
import Script from "next/script";
88
import { Toaster } from "@/components/ui/sonner";
9-
import { AIAgent } from "@/app/components/ai-agent";
109
import { figtree, geistMono, geistSans, switzer } from "./fonts";
10+
import { lazy } from "react";
11+
12+
const AIAgent = lazy(() => import("@/app/components/ai-agent"));
1113

1214
export const metadata: Metadata = {
1315
title: data.title,

app/styles/ai-agent.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,37 @@
4545
#n8n-chat .chat-window-toggle {
4646
@apply bg-[#3276F5];
4747
}
48+
49+
@media screen and (min-width: 992px) {
50+
#n8n-chat .chat-window-toggle {
51+
@apply mx-auto !hidden;
52+
}
53+
54+
#n8n-chat .chat-header {
55+
@apply relative;
56+
}
57+
58+
#n8n-chat .chat-window-wrapper.chat-window-minimized {
59+
@apply -z-10 mr-8 opacity-0;
60+
}
61+
62+
#n8n-chat .chat-window-wrapper {
63+
@apply bottom-0 mx-auto mt-8 h-[600px] w-full transition-all duration-300;
64+
}
65+
66+
#n8n-chat .chat-window-wrapper.chat-window-centered {
67+
@apply left-0 mx-auto max-w-4xl;
68+
}
69+
70+
#n8n-chat .chat-window-wrapper.chat-window-right {
71+
@apply right-0 mx-auto mr-4 max-w-lg;
72+
}
73+
74+
#n8n-chat .chat-window {
75+
@apply w-full;
76+
}
77+
}
78+
79+
#n8n-chat .chat-window-toggle {
80+
@apply bg-[#3276F5];
81+
}

0 commit comments

Comments
 (0)