diff --git a/.gitattributes b/.gitattributes index 777320c4..8fc02793 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ llm/** filter=lfs diff=lfs merge=lfs -text +*.md text eol=lf diff --git a/web/DESIGN.md b/web/DESIGN.md index c480f99b..633ab51a 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -28,37 +28,37 @@ Use **semantic CSS variables** in components (Tailwind: `bg-background`, `text-t ### Brand and interactive -| Role | Variable | Light default | Usage | -|------|-----------|---------------|--------| -| Accent | `--color-accent` | `#2b70c9` | Links, primary buttons, focus rings, key highlights | -| Accent hover / active | `--color-accent-hover`, `--color-accent-active` | `#205497`, `#163865` | Pressed / hover states | -| Primary (brand weight) | `--color-primary` | `#29457a` | Strong brand moments (varies by screen) | -| Text link | `--color-text-link` | `#2b70c9` | Inline links | +| Role | Variable | Light default | Usage | +| ---------------------- | ----------------------------------------------- | -------------------- | --------------------------------------------------- | +| Accent | `--color-accent` | `#2b70c9` | Links, primary buttons, focus rings, key highlights | +| Accent hover / active | `--color-accent-hover`, `--color-accent-active` | `#205497`, `#163865` | Pressed / hover states | +| Primary (brand weight) | `--color-primary` | `#29457a` | Strong brand moments (varies by screen) | +| Text link | `--color-text-link` | `#2b70c9` | Inline links | ### Surfaces -| Role | Variable | Light default | -|------|-----------|---------------| -| Page background | `--color-background` | `#fff` | -| Soft panels | `--color-surface` | `#f8f9ff` | -| Raised / cards / sidebar | `--color-surface-raised`, `--color-surface-sidebar` | `#ecedf6` | -| Note cards | `--color-note-card` | `#ecedf6` | +| Role | Variable | Light default | +| ------------------------ | --------------------------------------------------- | ------------- | +| Page background | `--color-background` | `#fff` | +| Soft panels | `--color-surface` | `#f8f9ff` | +| Raised / cards / sidebar | `--color-surface-raised`, `--color-surface-sidebar` | `#ecedf6` | +| Note cards | `--color-note-card` | `#ecedf6` | ### Text -| Role | Variable | Light default | -|------|-----------|---------------| -| Primary | `--color-text` | `#24242d` | +| Role | Variable | Light default | +| --------- | -------------------------------------------- | --------------------- | +| Primary | `--color-text` | `#24242d` | | Secondary | `--color-text-weak` … `--color-text-weakest` | `#383850` → `#a4a4b2` | -| Muted UI | `--color-text-idle` | `#57585e` | +| Muted UI | `--color-text-idle` | `#57585e` | ### Borders and icons -| Role | Variable | Light default | -|------|-----------|---------------| -| Default border | `--color-border` | `#d9dbeb` | -| Strong border | `--color-border-strong` | `#c8c8d4` | -| Icons | `--color-icon` | `#5f5f77` | +| Role | Variable | Light default | +| -------------- | ----------------------- | ------------- | +| Default border | `--color-border` | `#d9dbeb` | +| Strong border | `--color-border-strong` | `#c8c8d4` | +| Icons | `--color-icon` | `#5f5f77` | ### Status (use full scale: base + weak/weaker + hover/active) @@ -76,10 +76,10 @@ Some flows (e.g. profile appearance) allow a **user accent** such as `#2B70C9`, ### Font families -| Role | Variable / stack | Usage | -|------|------------------|--------| -| Display | `--font-display`: **Saira**, `sans-serif` | Headings (`text-h1` … `text-h6`), button text utilities | -| Body / UI | `--font-body`: **Inter**, `sans-serif` | Body copy, labels, dense UI | +| Role | Variable / stack | Usage | +| --------- | ----------------------------------------- | ------------------------------------------------------- | +| Display | `--font-display`: **Saira**, `sans-serif` | Headings (`text-h1` … `text-h6`), button text utilities | +| Body / UI | `--font-body`: **Inter**, `sans-serif` | Body copy, labels, dense UI | Google Fonts are loaded from `index.html` (Inter + Saira, variable weights). @@ -87,16 +87,16 @@ Google Fonts are loaded from `index.html` (Inter + Saira, variable weights). Sizes are defined as `@theme` variables and applied via classes in `@layer utilities` in `tailwind.css`: -| Utility | Font | Size | Weight (typical) | -|---------|------|------|-------------------| -| `text-h1` … `text-h4` | Saira | 40px … 24px | 700 | -| `text-h5` | Saira | 20px | 500 | -| `text-h6` | Saira | 18px | 400 | -| `text-body-xl-strong` | Inter | 18px | 700 | -| `text-body-l`, `text-body-l-strong` | Inter | 16px | 400 / 700 | -| `text-body-m`, `text-label-*` | Inter | 14px | 400 | -| `text-body-s`, `text-body-s-strong` | Inter | 12px | 400 / 700 | -| `text-button-m`, `text-button-s` | Saira | 14px / 12px | 400 | +| Utility | Font | Size | Weight (typical) | +| ----------------------------------- | ----- | ----------- | ---------------- | +| `text-h1` … `text-h4` | Saira | 40px … 24px | 700 | +| `text-h5` | Saira | 20px | 500 | +| `text-h6` | Saira | 18px | 400 | +| `text-body-xl-strong` | Inter | 18px | 700 | +| `text-body-l`, `text-body-l-strong` | Inter | 16px | 400 / 700 | +| `text-body-m`, `text-label-*` | Inter | 14px | 400 | +| `text-body-s`, `text-body-s-strong` | Inter | 12px | 400 / 700 | +| `text-button-m`, `text-button-s` | Saira | 14px / 12px | 400 | ### Principles @@ -199,9 +199,9 @@ There is no single “hamburger at 1024px” rule in this doc — match each rou ### Example prompts -- *“Build a settings card: background `var(--color-surface-raised)`, 10px radius, border `var(--color-border)`. Section title Saira 13px semibold, body Inter 13px `var(--color-text-weaker)`. Primary button filled `var(--color-accent)` white text.”* -- *“Create a form row: label Inter 12px `var(--color-text-idle)`, input border `var(--color-border)`, focus ring using `var(--color-accent)` at ~22% opacity.”* -- *“Hero for TalkUp: headline Saira `text-h1`, subcopy Inter `text-body-l`, CTA primary accent — cool white/lavender surfaces, not warm beige.”* +- _“Build a settings card: background `var(--color-surface-raised)`, 10px radius, border `var(--color-border)`. Section title Saira 13px semibold, body Inter 13px `var(--color-text-weaker)`. Primary button filled `var(--color-accent)` white text.”_ +- _“Create a form row: label Inter 12px `var(--color-text-idle)`, input border `var(--color-border)`, focus ring using `var(--color-accent)` at ~22% opacity.”_ +- _“Hero for TalkUp: headline Saira `text-h1`, subcopy Inter `text-body-l`, CTA primary accent — cool white/lavender surfaces, not warm beige.”_ ### Iteration checklist @@ -214,4 +214,4 @@ There is no single “hamburger at 1024px” rule in this doc — match each rou ## 10. Relation to external inspiration -Earlier drafts referenced **ElevenLabs**-style marketing aesthetics (warm stone, Waldenburg, extreme pill buttons, layered shadows). **That is not the TalkUp baseline.** You may still borrow *ideas* (clarity, whitespace, strong hierarchy) as long as **colors, fonts, and tokens** stay consistent with this file and `src/styles/tailwind.css`. +Earlier drafts referenced **ElevenLabs**-style marketing aesthetics (warm stone, Waldenburg, extreme pill buttons, layered shadows). **That is not the TalkUp baseline.** You may still borrow _ideas_ (clarity, whitespace, strong hierarchy) as long as **colors, fonts, and tokens** stay consistent with this file and `src/styles/tailwind.css`. diff --git a/web/src/components/atoms/base-input/index.tsx b/web/src/components/atoms/base-input/index.tsx index af89537f..2157343f 100644 --- a/web/src/components/atoms/base-input/index.tsx +++ b/web/src/components/atoms/base-input/index.tsx @@ -23,44 +23,49 @@ export type { BaseInputProps }; * @param {string} [props.className] - Additional CSS classes to apply. * @returns {JSX.Element} The rendered input component. */ -export const BaseInput: React.FC = (props) => { - const { - id, - name = 'input', - value = '', - type = 'text', - placeholder = 'Enter text', - disabled = false, - readOnly = false, - required = false, - onChange = () => {}, - className, - ...rest - } = props as BaseInputProps; - const generatedId = useId(); - const inputId = id || generatedId; +export const BaseInput = React.forwardRef( + (props, ref) => { + const { + id, + name = 'input', + value = '', + type = 'text', + placeholder = 'Enter text', + disabled = false, + readOnly = false, + required = false, + onChange = () => {}, + className, + ...rest + } = props as BaseInputProps; + const generatedId = useId(); + const inputId = id || generatedId; - return ( - - ); -}; + return ( + + ); + }, +); + +BaseInput.displayName = 'BaseInput'; diff --git a/web/src/components/atoms/chatbot/ChatAvatar.tsx b/web/src/components/atoms/chatbot/ChatAvatar.tsx new file mode 100644 index 00000000..fc4d5b90 --- /dev/null +++ b/web/src/components/atoms/chatbot/ChatAvatar.tsx @@ -0,0 +1,42 @@ +/** + * ChatAvatar + * + * Displays a small circular avatar inside the chatbot interface. + * The 'ai' variant uses the TalkUp gradient with a "T" initial. + * The 'user' variant uses a neutral background with a "U" initial. + * + * @param props - ChatAvatarProps + * @returns A circular avatar badge as a React functional component. + * + * @example + * + * + */ + +interface ChatAvatarProps { + /** 'ai' for the TalkUp assistant avatar, 'user' for the current user */ + variant: 'ai' | 'user'; + /** Visual size — 'sm' for message rows, 'lg' for the window header */ + size?: 'sm' | 'lg'; +} + +const sizeClasses = { + sm: 'w-7 h-7 text-body-s', + lg: 'w-9 h-9 text-body-m', +} as const; + +export const ChatAvatar = ({ variant, size = 'sm' }: ChatAvatarProps) => { + const isAi = variant === 'ai'; + + return ( +
+ {isAi ? 'T' : 'U'} +
+ ); +}; diff --git a/web/src/components/atoms/chatbot/ChatBubble.tsx b/web/src/components/atoms/chatbot/ChatBubble.tsx new file mode 100644 index 00000000..036093b9 --- /dev/null +++ b/web/src/components/atoms/chatbot/ChatBubble.tsx @@ -0,0 +1,37 @@ +/** + * ChatBubble + * + * Renders a single message bubble inside the chatbot widget. + * Supports two variants: 'ai' (left-aligned, surface background) and + * 'user' (right-aligned, TalkUp gradient background). + * + * @param props - ChatBubbleProps + * @returns A styled message bubble as a React functional component. + * + * @example + * + * + */ + +interface ChatBubbleProps { + /** The text content of the message */ + message: string; + /** Visual variant: 'ai' for assistant messages, 'user' for user messages */ + variant: 'ai' | 'user'; +} + +export const ChatBubble = ({ message, variant }: ChatBubbleProps) => { + const isAi = variant === 'ai'; + + return ( +
+ {message} +
+ ); +}; diff --git a/web/src/components/atoms/chatbot/ChatInput.tsx b/web/src/components/atoms/chatbot/ChatInput.tsx new file mode 100644 index 00000000..4d79163d --- /dev/null +++ b/web/src/components/atoms/chatbot/ChatInput.tsx @@ -0,0 +1,71 @@ +import { BaseInput } from '@/components/atoms/base-input'; +import React from 'react'; + +/** + * ChatInput + * + * A controlled single-line text input for the chatbot message bar. + * Thin wrapper around the shared BaseInput atom so focus-ring, disabled and + * accessibility behaviour stay consistent with the rest of the design system. + * Triggers onSend when the user presses Enter (without Shift). + * + * @param props - ChatInputProps + * @returns A styled text input as a React functional component. + * + * @example + * + */ + +interface ChatInputProps { + /** Current value of the input */ + value: string; + /** Callback fired on each keystroke */ + onChange: (value: string) => void; + /** Callback fired when the user presses Enter */ + onSend: () => void; + /** Placeholder text */ + placeholder?: string; + /** Whether the input is disabled */ + disabled?: boolean; +} + +export const ChatInput = React.forwardRef( + ( + { + value, + onChange, + onSend, + placeholder = 'Ask a question...', + disabled = false, + }, + ref, + ) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + return ( + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className="flex-1 rounded-xl bg-background focus:bg-surface" + /> + ); + }, +); + +ChatInput.displayName = 'ChatInput'; diff --git a/web/src/components/atoms/chatbot/TypingIndicator.tsx b/web/src/components/atoms/chatbot/TypingIndicator.tsx new file mode 100644 index 00000000..c15e9f0e --- /dev/null +++ b/web/src/components/atoms/chatbot/TypingIndicator.tsx @@ -0,0 +1,31 @@ +/** + * TypingIndicator + * + * Displays an animated three-dot indicator to signal that the TalkUp AI + * is currently generating a response. Uses CSS animations via Tailwind. + * + * @returns An animated typing indicator as a React functional component. + * + * @example + * {isTyping && } + */ + +export const TypingIndicator = () => { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +}; diff --git a/web/src/components/atoms/icon/icon-map.ts b/web/src/components/atoms/icon/icon-map.ts index 6da49e21..f40ec597 100644 --- a/web/src/components/atoms/icon/icon-map.ts +++ b/web/src/components/atoms/icon/icon-map.ts @@ -64,6 +64,7 @@ import { PiNotePencil, PiPaintBrushBroad, PiPalette, + PiPaperPlaneTilt, PiPencilSimple, PiPhoneFill, PiPhoneSlashFill, @@ -124,6 +125,7 @@ export const iconMap = { download: FaDownload, upload: FaUpload, chat: FaComments, + send: PiPaperPlaneTilt, help: PiQuestion, progression: PiChartLine, schedule: FaCalendarAlt, diff --git a/web/src/components/molecules/chatbot/ChatInputBar.tsx b/web/src/components/molecules/chatbot/ChatInputBar.tsx new file mode 100644 index 00000000..d1d7f021 --- /dev/null +++ b/web/src/components/molecules/chatbot/ChatInputBar.tsx @@ -0,0 +1,81 @@ +import { Button } from '@/components/atoms/button'; +import { ChatInput } from '@/components/atoms/chatbot/ChatInput'; +import { Icon } from '@/components/atoms/icon'; + +/** + * ChatInputBar + * + * Combines ChatInput + a send button into a single input bar. + * The send button is disabled when the input is empty or when isLoading is true. + * Triggers onSend both via the button click and via the ChatInput Enter key handler. + * + * @param props - ChatInputBarProps + * @returns A full input bar row as a React functional component. + * + * @example + * + */ + +interface ChatInputBarProps { + /** Current value of the text input */ + value: string; + /** Callback fired on each keystroke */ + onChange: (value: string) => void; + /** Callback fired when the user sends a message */ + onSend: () => void; + /** When true, disables input and send button while AI is responding */ + isLoading?: boolean; + /** Ref forwarded to the underlying text input */ + inputRef?: React.Ref; +} + +export const ChatInputBar = ({ + value, + onChange, + onSend, + isLoading = false, + inputRef, +}: ChatInputBarProps) => { + const canSend = value.trim().length > 0 && !isLoading; + + // Keep the input enabled while the AI responds so keyboard focus and tab + // order are not lost for the ~1.2s typing window; just suppress sending. + const handleSend = () => { + if (canSend) onSend(); + }; + + return ( +
+ + + +
+ ); +}; diff --git a/web/src/components/molecules/chatbot/ChatMessage.tsx b/web/src/components/molecules/chatbot/ChatMessage.tsx new file mode 100644 index 00000000..bb456ae1 --- /dev/null +++ b/web/src/components/molecules/chatbot/ChatMessage.tsx @@ -0,0 +1,63 @@ +import { ChatAvatar } from '@/components/atoms/chatbot/ChatAvatar'; +import { ChatBubble } from '@/components/atoms/chatbot/ChatBubble'; +import { TypingIndicator } from '@/components/atoms/chatbot/TypingIndicator'; + +/** + * ChatMessage + * + * Combines ChatAvatar + ChatBubble + timestamp into a single message row. + * Handles both 'ai' and 'user' layout (mirrored for user messages). + * Optionally renders a TypingIndicator instead of a bubble when isTyping is true. + * + * @param props - ChatMessageProps + * @returns A full message row as a React functional component. + * + * @example + * + * + * + */ + +interface ChatMessageProps { + /** The text content of the message */ + message?: string; + /** 'ai' for assistant, 'user' for current user */ + variant: 'ai' | 'user'; + /** Formatted timestamp string (e.g. "10:30") */ + timestamp?: string; + /** When true, renders a TypingIndicator instead of a bubble */ + isTyping?: boolean; +} + +export const ChatMessage = ({ + message, + variant, + timestamp, + isTyping = false, +}: ChatMessageProps) => { + const isAi = variant === 'ai'; + + return ( +
+ + +
+ {isTyping ? ( +
+ +
+ ) : ( + message && + )} + + {timestamp && !isTyping && ( + {timestamp} + )} +
+
+ ); +}; diff --git a/web/src/components/organisms/chatbot/ChatWidget.spec.tsx b/web/src/components/organisms/chatbot/ChatWidget.spec.tsx new file mode 100644 index 00000000..ee2e6a61 --- /dev/null +++ b/web/src/components/organisms/chatbot/ChatWidget.spec.tsx @@ -0,0 +1,92 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatWidget } from './ChatWidget'; + +describe('ChatWidget', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + const openChat = () => { + fireEvent.click(screen.getByRole('button', { name: 'Open TalkUp chat' })); + }; + + it('renders the FAB closed by default', () => { + render(); + expect( + screen.getByRole('button', { name: 'Open TalkUp chat' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + }); + + it('the FAB is type="button" so it never submits a surrounding form', () => { + const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault()); + render( +
+ + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Open TalkUp chat' })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('opens the chat panel and moves focus to the input', () => { + render(); + openChat(); + + expect(screen.getByRole('complementary')).toBeInTheDocument(); + expect(screen.getByLabelText('Chat message input')).toHaveFocus(); + }); + + it('closes on Escape and returns focus to the FAB', () => { + render(); + openChat(); + + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Open TalkUp chat' }), + ).toHaveFocus(); + }); + + it('renders a welcome message with a timestamp computed at mount', () => { + render(); + openChat(); + expect(screen.getByText(/Ask me anything/)).toBeInTheDocument(); + // A HH:MM timestamp is rendered alongside the welcome bubble. + expect(screen.getByText(/^\d{2}:\d{2}$/)).toBeInTheDocument(); + }); + + it('sends a user message and shows an AI reply after the delay', () => { + render(); + openChat(); + + const input = screen.getByLabelText('Chat message input'); + fireEvent.change(input, { target: { value: 'Hello there' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + expect(screen.getByText('Hello there')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1200); + }); + + expect(screen.getByText(/prioritization frameworks/)).toBeInTheDocument(); + }); + + it('does not send when the input is empty', () => { + render(); + openChat(); + const send = screen.getByRole('button', { name: 'Send message' }); + expect(send).toBeDisabled(); + }); +}); diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx new file mode 100644 index 00000000..51888615 --- /dev/null +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -0,0 +1,147 @@ +import { Button } from '@/components/atoms/button'; +import { Icon } from '@/components/atoms/icon'; +import { ChatWindow, Message } from '@/components/organisms/chatbot/ChatWindow'; +import { useDragFAB } from '@/hooks/ui/useDragFAB'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const WELCOME_TEXT = + "Hello! I'm TalkUp AI. Ask me anything to prepare for your interview 🎯"; + +const AI_REPLIES = [ + 'For a Product Manager interview, focus on prioritization frameworks like RICE or MoSCoW.', + 'Practice the STAR method: Situation, Task, Action, Result. It structures your answers clearly.', + 'Research the company', +]; + +const getTimestamp = () => + new Date().toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + }); + +export const ChatWidget = () => { + const [isOpen, setIsOpen] = useState(false); + // Computed on mount so the welcome timestamp reflects when the chat is first + // rendered, not when the module was loaded. + const [messages, setMessages] = useState(() => [ + { + id: 'welcome', + text: WELCOME_TEXT, + variant: 'ai', + timestamp: getTimestamp(), + }, + ]); + const [inputValue, setInputValue] = useState(''); + const [isTyping, setIsTyping] = useState(false); + + const fabRef = useRef(null); + const inputRef = useRef(null); + const replyIndex = useRef(0); + // Monotonic counter for message ids — avoids duplicate React keys when a user + // message and its AI reply land in the same millisecond. + const msgId = useRef(0); + const timeoutRef = useRef | undefined>( + undefined, + ); + + const { position, onMouseDown, onTouchStart, isDragging } = useDragFAB(); + + const handleSend = useCallback(() => { + const text = inputValue.trim(); + if (!text || isTyping) return; + + const userMsg: Message = { + id: `user-${(msgId.current += 1)}`, + text, + variant: 'user', + timestamp: getTimestamp(), + }; + + setMessages((prev) => [...prev, userMsg]); + setInputValue(''); + setIsTyping(true); + + timeoutRef.current = setTimeout(() => { + const aiMsg: Message = { + id: `ai-${(msgId.current += 1)}`, + text: AI_REPLIES[replyIndex.current % AI_REPLIES.length], + variant: 'ai', + timestamp: getTimestamp(), + }; + replyIndex.current += 1; + setMessages((prev) => [...prev, aiMsg]); + setIsTyping(false); + }, 1200); + }, [inputValue, isTyping]); + + useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + + const handleFabClick = () => { + if (isDragging.current) return; + setIsOpen((prev) => !prev); + }; + + const closeChat = useCallback(() => { + setIsOpen(false); + fabRef.current?.focus(); + }, []); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) closeChat(); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen, closeChat]); + + // Move focus into the panel when it opens so keyboard and screen-reader users + // land inside the dialog rather than tabbing through the page behind it. + useEffect(() => { + if (isOpen) inputRef.current?.focus(); + }, [isOpen]); + + return ( +
+ {isOpen && ( +
+ +
+ )} + + {/* ── FAB ── */} + +
+ ); +}; diff --git a/web/src/components/organisms/chatbot/ChatWindow.tsx b/web/src/components/organisms/chatbot/ChatWindow.tsx new file mode 100644 index 00000000..7d63a6f0 --- /dev/null +++ b/web/src/components/organisms/chatbot/ChatWindow.tsx @@ -0,0 +1,125 @@ +import { ChatAvatar } from '@/components/atoms/chatbot/ChatAvatar'; +import { ChatInputBar } from '@/components/molecules/chatbot/ChatInputBar'; +import { ChatMessage } from '@/components/molecules/chatbot/ChatMessage'; +import { useEffect, useRef } from 'react'; + +/** + * ChatWindow + * + * The main conversation panel of the TalkUp chatbot widget. + * Renders the header with AI status, the scrollable message list, + * the input bar, and a branded footer. + * + * Auto-scrolls to the latest message whenever messages change. + * + * @param props - ChatWindowProps + * @returns The full chat panel as a React functional component. + * + * @example + * + */ + +export interface Message { + /** Unique identifier for the message */ + id: string; + /** Text content */ + text: string; + /** 'ai' for assistant messages, 'user' for user messages */ + variant: 'ai' | 'user'; + /** Formatted time string */ + timestamp: string; +} + +interface ChatWindowProps { + /** List of messages to display */ + messages: Message[]; + /** Current value of the input */ + inputValue: string; + /** Callback fired on input change */ + onInputChange: (value: string) => void; + /** Callback fired when the user sends a message */ + onSend: () => void; + /** When true, shows a typing indicator at the bottom of the list */ + isTyping?: boolean; + /** Ref forwarded to the text input so the widget can focus it on open */ + inputRef?: React.Ref; +} + +export const ChatWindow = ({ + messages, + inputValue, + onInputChange, + onSend, + isTyping = false, + inputRef, +}: ChatWindowProps) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isTyping]); + + return ( +
+ {/* ── Header ── */} +
+ +
+

+ TalkUp AI +

+

+ + Online — ready to help +

+
+
+ + {/* ── Messages ── */} +
+ {messages.map((msg) => ( + + ))} + + {isTyping && } + +
+
+ + {/* ── Input ── */} + + + {/* ── Footer ── */} +
+ Powered by TalkUp AI +
+
+ ); +}; diff --git a/web/src/hooks/ui/useDragFAB.spec.ts b/web/src/hooks/ui/useDragFAB.spec.ts new file mode 100644 index 00000000..abab5c44 --- /dev/null +++ b/web/src/hooks/ui/useDragFAB.spec.ts @@ -0,0 +1,154 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useDragFAB } from './useDragFAB'; + +const FAB_SIZE = 56; +const ANCHOR_GAP = 24; + +/** Build a minimal React.MouseEvent-ish object for the press handlers. */ +const mouseEvent = (clientX: number, clientY: number) => + ({ clientX, clientY }) as unknown as React.MouseEvent; + +/** Build a minimal React.TouchEvent-ish object for the press handlers. */ +const touchEvent = (clientX: number, clientY: number) => + ({ + touches: [{ clientX, clientY }], + }) as unknown as React.TouchEvent; + +const fireMouseMove = (clientX: number, clientY: number) => + document.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY })); + +const fireMouseUp = () => document.dispatchEvent(new MouseEvent('mouseup')); + +describe('useDragFAB', () => { + beforeEach(() => { + vi.useFakeTimers(); + // jsdom defaults to 1024x768; pin it so clamp math is deterministic. + Object.defineProperty(window, 'innerWidth', { + value: 1024, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: 768, + configurable: true, + }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('starts anchored at the corner with no offset', () => { + const { result } = renderHook(() => useDragFAB()); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + expect(result.current.isDragging.current).toBe(false); + }); + + it('updates position as the pointer moves', () => { + const { result } = renderHook(() => useDragFAB()); + + act(() => result.current.onMouseDown(mouseEvent(100, 100))); + act(() => fireMouseMove(90, 80)); + + // offset = clientNow - (clientStart - prevOffset) = 90-100, 80-100 + expect(result.current.position).toEqual({ x: -10, y: -20 }); + expect(result.current.isDragging.current).toBe(true); + }); + + it('clamps the offset so the FAB cannot leave the viewport', () => { + const { result } = renderHook(() => useDragFAB()); + + // Drag far past the top-left corner. + act(() => result.current.onMouseDown(mouseEvent(100, 100))); + act(() => fireMouseMove(-5000, -5000)); + + const minX = ANCHOR_GAP - (window.innerWidth - FAB_SIZE); + const minY = ANCHOR_GAP - (window.innerHeight - FAB_SIZE); + expect(result.current.position.x).toBe(minX); + expect(result.current.position.y).toBe(minY); + + // Drag far past the bottom-right corner: capped at the resting gap. + act(() => result.current.onMouseDown(mouseEvent(0, 0))); + act(() => fireMouseMove(5000, 5000)); + expect(result.current.position.x).toBe(ANCHOR_GAP); + expect(result.current.position.y).toBe(ANCHOR_GAP); + }); + + it('treats a press with no movement as a tap (isDragging stays false)', () => { + const { result } = renderHook(() => useDragFAB()); + + act(() => result.current.onMouseDown(mouseEvent(50, 50))); + act(() => fireMouseUp()); + + // The click guard timer has not yet cleared, but no move ever fired. + expect(result.current.isDragging.current).toBe(false); + }); + + it('keeps isDragging true through the synthetic click after a drag, then clears it', () => { + const { result } = renderHook(() => useDragFAB()); + + act(() => result.current.onMouseDown(mouseEvent(100, 100))); + act(() => fireMouseMove(40, 40)); + act(() => fireMouseUp()); + + // Immediately after release the flag is still set so the click handler + // can suppress the toggle. + expect(result.current.isDragging.current).toBe(true); + + act(() => vi.advanceTimersByTime(60)); + expect(result.current.isDragging.current).toBe(false); + }); + + it('does not leak listeners when a second press starts before the first ends', () => { + const { result } = renderHook(() => useDragFAB()); + + const removeSpy = vi.spyOn(document, 'removeEventListener'); + + // First press, no mouseup. + act(() => result.current.onMouseDown(mouseEvent(100, 100))); + // Second press while the first is still "held" must tear down the first. + act(() => result.current.onMouseDown(mouseEvent(200, 200))); + + expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)); + + // After the cleanup, the stale first listener must no longer move the FAB. + // Only the second press's listener is live now. + act(() => fireMouseMove(180, 180)); + // offset from the second press: 180-200 = -20 on both axes. + expect(result.current.position).toEqual({ x: -20, y: -20 }); + + removeSpy.mockRestore(); + }); + + it('removes document listeners on unmount mid-drag', () => { + const { result, unmount } = renderHook(() => useDragFAB()); + const removeSpy = vi.spyOn(document, 'removeEventListener'); + + act(() => result.current.onMouseDown(mouseEvent(10, 10))); + unmount(); + + expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)); + removeSpy.mockRestore(); + }); + + it('supports touch input through the same code path', () => { + const { result } = renderHook(() => useDragFAB()); + + act(() => result.current.onTouchStart(touchEvent(100, 100))); + act(() => + document.dispatchEvent( + Object.assign(new Event('touchmove'), { + touches: [{ clientX: 70, clientY: 60 }], + preventDefault: () => {}, + }), + ), + ); + + expect(result.current.position).toEqual({ x: -30, y: -40 }); + expect(result.current.isDragging.current).toBe(true); + }); +}); diff --git a/web/src/hooks/ui/useDragFAB.ts b/web/src/hooks/ui/useDragFAB.ts new file mode 100644 index 00000000..7570c5c6 --- /dev/null +++ b/web/src/hooks/ui/useDragFAB.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** FAB diameter in px (w-14 h-14). Used to keep the button inside the viewport. */ +const FAB_SIZE = 56; +/** Resting gap from the anchored corner in px (bottom-24 / right-24). */ +const ANCHOR_GAP = 24; +/** + * Delay before clearing the drag flag after a press ends. A synthetic click + * fires right after mouseup/touchend; keeping the flag set briefly lets the + * FAB click handler tell a drag from a tap. + */ +const CLICK_GUARD_MS = 50; + +/** + * useDragFAB + * + * Encapsulates the drag behaviour of a floating action button. + * Handles both mouse and touch input through a single code path, + * tracks an offset position, and exposes an `isDragging` ref so a + * click handler can distinguish a drag from a tap. + * + * Document-level listeners are registered only while a drag is active + * and are always removed — both on drag end and on unmount — so they + * never leak if the component unmounts mid-drag. + * + * @returns position (offset from the anchored corner), the press + * handlers to spread onto the FAB, and the isDragging ref. + * + * @example + * const { position, onMouseDown, onTouchStart, isDragging } = useDragFAB(); + */ +export const useDragFAB = () => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const isDragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const cleanupRef = useRef<(() => void) | undefined>(undefined); + const clearDragRef = useRef | undefined>( + undefined, + ); + + const beginDrag = useCallback( + (clientX: number, clientY: number) => { + isDragging.current = false; + dragStart.current = { + x: clientX - position.x, + y: clientY - position.y, + }; + }, + [position], + ); + + const moveTo = useCallback((clientX: number, clientY: number) => { + isDragging.current = true; + // The FAB is anchored to the bottom-right corner via `bottom: ANCHOR_GAP - y` + // and `right: ANCHOR_GAP - x`. Clamp the offset so the button can never be + // dragged outside the viewport (which would leave it unreachable). + const rawX = clientX - dragStart.current.x; + const rawY = clientY - dragStart.current.y; + const minX = ANCHOR_GAP - (window.innerWidth - FAB_SIZE); + const minY = ANCHOR_GAP - (window.innerHeight - FAB_SIZE); + setPosition({ + x: Math.min(ANCHOR_GAP, Math.max(minX, rawX)), + y: Math.min(ANCHOR_GAP, Math.max(minY, rawY)), + }); + }, []); + + const endDrag = useCallback(() => { + cleanupRef.current?.(); + // Defer clearing the drag flag so the synthetic click that fires after a + // mouseup/touchend (tap-vs-drag detection) still sees isDragging === true. + // Same path for mouse and touch so behaviour can't diverge by device. + clearTimeout(clearDragRef.current); + clearDragRef.current = setTimeout(() => { + isDragging.current = false; + clearDragRef.current = undefined; + }, CLICK_GUARD_MS); + }, []); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + // Tear down any listeners still registered from a previous press that + // never received its mouseup/touchend, otherwise the FAB keeps following + // the pointer (overlapping-press listener leak). + cleanupRef.current?.(); + beginDrag(e.clientX, e.clientY); + + const onMove = (ev: MouseEvent) => moveTo(ev.clientX, ev.clientY); + const onUp = () => endDrag(); + + cleanupRef.current = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + cleanupRef.current = undefined; + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [beginDrag, moveTo, endDrag], + ); + + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + cleanupRef.current?.(); + const touch = e.touches[0]; + beginDrag(touch.clientX, touch.clientY); + + const onMove = (ev: TouchEvent) => { + ev.preventDefault(); + const t = ev.touches[0]; + moveTo(t.clientX, t.clientY); + }; + const onEnd = () => endDrag(); + + cleanupRef.current = () => { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + cleanupRef.current = undefined; + }; + + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + }, + [beginDrag, moveTo, endDrag], + ); + + useEffect(() => { + return () => { + cleanupRef.current?.(); + clearTimeout(clearDragRef.current); + }; + }, []); + + return { position, onMouseDown, onTouchStart, isDragging }; +}; diff --git a/web/src/routes/ai-chat.tsx b/web/src/routes/ai-chat.tsx index b140a928..71b5f1f5 100644 --- a/web/src/routes/ai-chat.tsx +++ b/web/src/routes/ai-chat.tsx @@ -1,50 +1,29 @@ -import { Button } from '@/components/atoms/button'; +import { ChatWidget } from '@/components/organisms/chatbot/ChatWidget'; import { createAuthGuard } from '@/utils/auth.guards'; import { createFileRoute } from '@tanstack/react-router'; -import toast from 'react-hot-toast'; +/** + * @route /ai-chat + * @description Entry point for the TalkUp AI chatbot session. + * Renders the floating ChatWidget scoped to this route. + */ export const Route = createFileRoute('/ai-chat')({ beforeLoad: createAuthGuard('/ai-chat'), - component: AIChat, + component: AiChatPage, }); -function AIChat() { +function AiChatPage() { return ( -
-

AI Chat

-

Chat IA

-
- - - +
+
+

+ TalkUp AI Session +

+

+ Click the button in the bottom-right corner to start chatting. +

+
); } diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css index c5b3818f..454f7f6d 100644 --- a/web/src/styles/tailwind.css +++ b/web/src/styles/tailwind.css @@ -331,4 +331,14 @@ font-weight: 400; line-height: 1.5; } + + /* TalkUp brand gradient — accent → success. Single source of truth for the + chatbot widget surfaces (FAB, header, send button, user bubble). */ + .bg-brand-gradient { + background-image: linear-gradient( + to bottom right, + var(--color-accent), + var(--color-success) + ); + } } diff --git a/web/src/tests/setupTests.ts b/web/src/tests/setupTests.ts index 4e076e8a..8906451f 100644 --- a/web/src/tests/setupTests.ts +++ b/web/src/tests/setupTests.ts @@ -6,6 +6,12 @@ afterEach(() => { cleanup(); }); +// JSDOM does not implement Element.prototype.scrollIntoView. Components that +// auto-scroll (e.g. the chat message list) call it on mount, so stub it. +if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + // JSDOM does not implement HTMLMediaElement.prototype.play which causes a noisy // "Not implemented" warning during tests. Stub it to a no-op Promise so tests // that call play() do not print the warning.