diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..25e77de Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/send.png b/public/images/send.png new file mode 100644 index 0000000..f6ad803 Binary files /dev/null and b/public/images/send.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3c74ad8..a5697d4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import MuiProvider from '@/components/layout/MuiProvider'; import QueryProvider from '@/components/layout/QueryProvider'; +import Chatbot from '@/components/Chatbot/Chatbot'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -31,6 +32,7 @@ export default function RootLayout({ {children} + ); diff --git a/src/components/Chatbot/Chatbot.css b/src/components/Chatbot/Chatbot.css new file mode 100644 index 0000000..3586301 --- /dev/null +++ b/src/components/Chatbot/Chatbot.css @@ -0,0 +1,266 @@ +/* IssueBot — matches the dark zinc theme of IssueHub */ +/* Uses CSS variables from globals.css + Geist font from next/font */ + +.chatbot-bubble { + position: fixed; + bottom: 28px; + right: 28px; + width: 56px; + height: 56px; + background: #fafafa; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1000; + transition: + transform 0.2s ease, + background 0.2s ease; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); +} + +.chatbot-bubble:hover { + transform: scale(1.08); + background: #e4e4e7; +} + +.chatbot-bubble .icon-chat path { + fill: #0a0a0b; +} + +.chatbot-bubble .icon-close line { + stroke: #0a0a0b; +} + +.icon-chat, +.icon-close { + position: absolute; + transition: + transform 0.25s ease, + opacity 0.2s ease; +} + +.icon-close { + opacity: 0; + transform: scale(0.6) rotate(-20deg); +} + +.chatbot-bubble.open .icon-chat { + opacity: 0; + transform: scale(0.6) rotate(20deg); +} + +.chatbot-bubble.open .icon-close { + opacity: 1; + transform: scale(1) rotate(0deg); +} + +/* ── Container ────────────────────────────────────────────────────────────── */ +.chatbot-container { + height: 420px; + width: 350px; + background: #18181b; /* --muted: zinc-900 */ + border: 1px solid #27272a; /* --border: zinc-800 */ + position: fixed; + bottom: 96px; + right: 28px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.7); + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + z-index: 999; + font-family: var(--font-geist-sans, sans-serif); + color: #fafafa; + + transform-origin: bottom right; + transform: scale(0.85) translateY(16px); + opacity: 0; + pointer-events: none; + transition: + transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.2s ease; +} + +.chatbot-container.open { + transform: scale(1) translateY(0); + opacity: 1; + pointer-events: all; +} + +/* ── Header ───────────────────────────────────────────────────────────────── */ +.chatbot-header { + height: 56px; + display: flex; + align-items: center; + padding: 0 16px; + background: #0a0a0b; + border-bottom: 1px solid #27272a; + flex-shrink: 0; + gap: 10px; +} + +.chatbot-logo { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.chatbot-title { + font-size: 0.95rem; + font-weight: 600; + color: #fafafa; + letter-spacing: 0.01em; +} + +.chatbot-minimize-btn { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + padding: 6px; + border-radius: 6px; + color: #a1a1aa; + transition: + color 0.15s ease, + background 0.15s ease; +} + +.chatbot-minimize-btn:hover { + color: #fafafa; + background: #27272a; +} + +/* ── Body ─────────────────────────────────────────────────────────────────── */ +.chatbot-body { + flex: 1; + display: flex; + flex-direction: column; + padding: 12px 10px; + align-items: flex-end; + overflow-y: auto; + gap: 2px; +} + +.chatbot-body::-webkit-scrollbar { + width: 4px; +} +.chatbot-body::-webkit-scrollbar-track { + background: transparent; +} +.chatbot-body::-webkit-scrollbar-thumb { + background: #27272a; + border-radius: 4px; +} + +/* ── Messages ─────────────────────────────────────────────────────────────── */ +.chatbot-bot-msg, +.chatbot-user-msg { + padding: 8px 12px; + margin: 3px 0; + max-width: 82%; + font-size: 0.875rem; + line-height: 1.45; + word-break: break-word; +} + +.chatbot-user-msg { + background: #27272a; + color: #fafafa; + align-self: flex-end; + border-radius: 12px 3px 12px 12px; +} + +.chatbot-bot-msg { + background: #3f3f46; + color: #fafafa; + align-self: flex-start; + border-radius: 3px 12px 12px 12px; +} + +/* ── Welcome ──────────────────────────────────────────────────────────────── */ +.chatbot-welcome { + display: flex; + flex-direction: column; + gap: 8px; +} + +.chatbot-recommended-label { + font-size: 0.68rem; + font-weight: 600; + color: #a1a1aa; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.chatbot-option-btn { + background: transparent; + color: #fafafa; + border: 1px solid #3f3f46; + border-radius: 20px; + padding: 5px 14px; + font-size: 0.8rem; + font-family: var(--font-geist-sans, sans-serif); + cursor: pointer; + align-self: flex-start; + transition: + background 0.2s, + border-color 0.2s; +} + +.chatbot-option-btn:hover { + background: #27272a; + border-color: #a1a1aa; +} + +/* ── Input row ────────────────────────────────────────────────────────────── */ +.chatbot-input-row { + height: 52px; + display: flex; + align-items: center; + border-top: 1px solid #27272a; + background: #0a0a0b; + flex-shrink: 0; + padding: 0 4px 0 0; +} + +.chatbot-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: #fafafa; + caret-color: #fafafa; + font-size: 0.9rem; + font-family: var(--font-geist-sans, sans-serif); + padding: 8px 12px; +} + +.chatbot-input::placeholder { + color: #52525b; +} + +.chatbot-send-btn { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + border-radius: 8px; + opacity: 0.6; + transition: + opacity 0.15s ease, + background 0.15s ease; +} + +.chatbot-send-btn:hover { + opacity: 1; + background: #27272a; +} diff --git a/src/components/Chatbot/Chatbot.tsx b/src/components/Chatbot/Chatbot.tsx new file mode 100644 index 0000000..87b7946 --- /dev/null +++ b/src/components/Chatbot/Chatbot.tsx @@ -0,0 +1,295 @@ +'use client'; + +import Image from 'next/image'; +import { useEffect, useRef, useState } from 'react'; +import { z } from 'zod'; +import './Chatbot.css'; + +// ── Response map ────────────────────────────────────────────────────────────── +// `today` and `time` are computed on call, not at module load +type ResponseValue = string | (() => string) | (() => void); + +const responseObj: Record = { + hello: 'Hey! How are you doing?', + hey: "Hey! What's up?", + today: () => new Date().toDateString(), + time: () => new Date().toLocaleTimeString(), + ping: 'Pong', + rn: () => '__EMAIL_FLOW__', + about: 'My role is to increase website traffic.', +}; + +// ── Email validation via zod ────────────────────────────────────────────────── +const emailSchema = z.string().email(); + +// ── Types ───────────────────────────────────────────────────────────────────── +interface Message { + id: number; + text: string; + type: 'bot' | 'user'; + isWelcome?: boolean; +} + +let msgId = 0; +const nextId = () => ++msgId; + +// ── Component ───────────────────────────────────────────────────────────────── +export default function Chatbot() { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [placeholder, setPlaceholder] = useState('Type here'); + const [isEmailMode, setIsEmailMode] = useState(false); + const [welcomeRendered, setWelcomeRendered] = useState(false); + + const chatBodyRef = useRef(null); + const inputRef = useRef(null); + + // Scroll to bottom on new messages + useEffect(() => { + if (chatBodyRef.current) { + chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight; + } + }, [messages]); + + // Focus input and render welcome on first open + useEffect(() => { + if (isOpen) { + setTimeout(() => inputRef.current?.focus(), 260); + if (!welcomeRendered) { + setMessages([{ id: nextId(), text: '', type: 'bot', isWelcome: true }]); + setWelcomeRendered(true); + } + } + }, [isOpen, welcomeRendered]); + + // ── Helpers ──────────────────────────────────────────────────────────────── + const addMessage = (text: string, type: 'bot' | 'user' = 'bot') => + setMessages(prev => [...prev, { id: nextId(), text, type }]); + + const removeLastMessage = () => setMessages(prev => prev.slice(0, -1)); + + // ── Email flow ───────────────────────────────────────────────────────────── + function startEmailFlow() { + setIsEmailMode(true); + addMessage('Please enter your email:'); + setPlaceholder('your@email.com'); + setTimeout(() => inputRef.current?.focus(), 50); + } + + const submitEmail = async (email: string) => { + const result = emailSchema.safeParse(email); + if (!result.success) { + addMessage('Invalid email. Please try again.'); + return; + } + + addMessage(email, 'user'); + setInputValue(''); + addMessage('Sending... ⏳'); + + try { + const emailjs = await import('@emailjs/browser'); + await emailjs.send( + 'YOUR_SERVICE', + 'YOUR_TEMPLATE', + { user_email: email, user: 'IssueHub' }, + { publicKey: 'YOUR_APIKEY' } + ); + removeLastMessage(); + addMessage('Email sent successfully! ✅'); + } catch (err) { + removeLastMessage(); + addMessage('Failed to send. Please try again. ❌'); + console.error(err); + } + + setIsEmailMode(false); + setPlaceholder('Type here'); + }; + + // ── Message handler ──────────────────────────────────────────────────────── + const handleSend = () => { + const text = inputValue.trim(); + if (!text) return; + + if (isEmailMode) { + submitEmail(text); + return; + } + + addMessage(text, 'user'); + setInputValue(''); + + setTimeout(() => { + const res = responseObj[text.toLowerCase()]; + + if (typeof res === 'function') { + const result = res(); + if (result === '__EMAIL_FLOW__') { + startEmailFlow(); + } else if (typeof result === 'string') { + addMessage(result); + } + } else if (typeof res === 'string') { + addMessage(res); + } else { + addMessage("Sorry, I didn't understand that. Try: hello, today, time, ping, rn."); + } + }, 500); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSend(); + }; + + // ── Render ───────────────────────────────────────────────────────────────── + return ( + <> + {/* Chat window */} +
+ {/* Header */} +
+
+ IssueBot logo +
+ IssueBot + +
+ + {/* Body */} +
+ {messages.map(msg => + msg.isWelcome ? ( + + ) : ( +
+ {msg.text} +
+ ) + )} +
+ + {/* Input */} +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+
+ + {/* Bubble toggle */} + + + ); +} + +// ── Welcome sub-component ───────────────────────────────────────────────────── +function WelcomeMessage({ onNotify }: { onNotify: () => void }) { + const [dismissed, setDismissed] = useState(false); + + const handleClick = () => { + setDismissed(true); + onNotify(); + }; + + return ( +
+ Hi! How can I help you today? + {!dismissed && ( + <> + Recommended + + + )} +
+ ); +}