import * as React from "react";
import {
Bot,
UserRound,
MessageCircle,
X,
ChevronDown,
Send,
Smile,
Paperclip,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { resolveBrandVars } from "@/lib/colors";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MarkdownContent } from "@/components/ui/markdown-content";
/**
* ChatWidget Primitives — WealthX DS (Atoms)
*
* Atomic building blocks for the embeddable broker website chat widget.
* All atoms are pure display components — no API calls, no state beyond local UI.
*
* Exports:
* ChatWidgetLauncher — Floating action button (open/close)
* ChatWidgetHeader — Gradient branded header
* ChatWidgetMessage — Message bubble (bot | user | system)
* TypingDots — Bare three-bouncing-dots animation (shared atom)
* ChatWidgetTypingIndicator — Animated 3-dot indicator
* ChatWidgetInputBar — Footer input with emoji/attachment/send
*/
// ---------------------------------------------------------------------------
// ChatWidgetLauncher
// ---------------------------------------------------------------------------
export interface ChatWidgetLauncherProps {
/** Whether the chat window is currently open. */
isOpen: boolean;
onClick: () => void;
/**
* Solid brand color (hex) for the button background. Omit to inherit
* `--primary` from the nearest ThemeProvider. When set, the icon color is
* derived from it so it stays readable on light brand colors.
*/
brandColor?: string;
/** Number of unread messages — shows a red badge when > 0 and chat is closed. */
unreadCount?: number;
className?: string;
}
export function ChatWidgetLauncher({
isOpen,
onClick,
brandColor,
unreadCount,
className,
}: ChatWidgetLauncherProps) {
return (
);
}
// ---------------------------------------------------------------------------
// ChatWidgetHeader
// ---------------------------------------------------------------------------
export interface ChatWidgetHeaderProps {
brokerName: string;
subtitle?: string;
/** Gradient start color. */
gradientFrom?: string;
/** Gradient end color. */
gradientTo?: string;
onMinimize?: () => void;
className?: string;
}
export function ChatWidgetHeader({
brokerName,
subtitle = "Feel free to chat with us",
gradientFrom,
gradientTo,
onMinimize,
className,
}: ChatWidgetHeaderProps) {
const hasGradient = gradientFrom && gradientTo;
return (
{brokerName}
{subtitle && (
{subtitle}
)}
{onMinimize && (
)}
);
}
// ---------------------------------------------------------------------------
// ChatWidgetMessage
// ---------------------------------------------------------------------------
export type ChatWidgetMessageRole = "bot" | "advisor" | "user" | "system";
export interface ChatWidgetMessageProps {
role: ChatWidgetMessageRole;
content: string;
timestamp?: string;
/** True while the bot response is still streaming in — shows TypingIndicator instead. */
isStreaming?: boolean;
/**
* How to render `content`. `"plain"` (default) renders the raw string.
* `"markdown"` renders it as sanitized markdown (headings, lists, links,
* tables, code) — use for AI / assistant responses.
*/
format?: "plain" | "markdown";
className?: string;
}
export function ChatWidgetMessage({
role,
content,
timestamp,
isStreaming,
format = "plain",
className,
}: ChatWidgetMessageProps) {
if (role === "system") {
return (
{content}
);
}
const isLeft = role === "bot" || role === "advisor";
return (
{isLeft && (
{role === "advisor" ? (
) : (
)}
)}
{isStreaming ? (
) : format === "markdown" ? (
) : (
content
)}
{timestamp && !isStreaming && (
{timestamp}
)}
);
}
// ---------------------------------------------------------------------------
// TypingDots
// ---------------------------------------------------------------------------
export interface TypingDotsProps {
/**
* Size preset. `"default"` = size-1.5 rounded dots at /60 opacity (chat
* widget); `"sm"` = size-1 square dots at /70 opacity (copilot thinking
* steps). Bundles dot size, gap, shape and opacity so call sites stay
* pixel-identical to their prior hand-rolled markup.
*/
size?: "default" | "sm";
className?: string;
}
/**
* Bare three-bouncing-dots animation, shared by `ChatWidgetTypingIndicator`
* and the copilot `CopilotThinkingSteps`. Renders an `aria-hidden` span — the
* accessible status text is owned by the consuming component.
*/
export function TypingDots({ size = "default", className }: TypingDotsProps) {
const sm = size === "sm";
return (
{[0, 1, 2].map((i) => (
))}
);
}
// ---------------------------------------------------------------------------
// ChatWidgetTypingIndicator
// ---------------------------------------------------------------------------
export interface ChatWidgetTypingIndicatorProps {
/**
* Optional label shown beside the animation, e.g. "Analysing your file…".
* When set it also becomes the accessible status text.
*/
label?: string;
/**
* Visual style. `"dots"` (default) = three bouncing dots for live typing.
* `"shimmer"` = a pulsing bar for a pre-stream "thinking" state.
*/
variant?: "dots" | "shimmer";
className?: string;
}
export function ChatWidgetTypingIndicator({
label,
variant = "dots",
className,
}: ChatWidgetTypingIndicatorProps) {
return (
{variant === "shimmer" ? (
) : (
)}
{label && (
{label}
)}
);
}
// ---------------------------------------------------------------------------
// ChatWidgetInputBar
// ---------------------------------------------------------------------------
export interface ChatWidgetInputBarProps {
value: string;
onChange: (value: string) => void;
onSend: (value: string) => void;
disabled?: boolean;
placeholder?: string;
className?: string;
}
export function ChatWidgetInputBar({
value,
onChange,
onSend,
disabled,
placeholder = "Type your message here",
className,
}: ChatWidgetInputBarProps) {
const hasText = value.trim().length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && hasText && !disabled) {
e.preventDefault();
onSend(value);
}
};
return (
);
}