@djangocfg/layouts 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +19 -13
  2. package/src/contexts/LeadsContext.tsx +156 -0
  3. package/src/contexts/NewsletterContext.tsx +263 -0
  4. package/src/contexts/SupportContext.tsx +256 -0
  5. package/src/contexts/index.ts +59 -0
  6. package/src/contexts/knowbase/ChatContext.tsx +174 -0
  7. package/src/contexts/knowbase/DocumentsContext.tsx +304 -0
  8. package/src/contexts/knowbase/SessionsContext.tsx +174 -0
  9. package/src/contexts/knowbase/index.ts +61 -0
  10. package/src/contexts/payments/BalancesContext.tsx +65 -0
  11. package/src/contexts/payments/CurrenciesContext.tsx +66 -0
  12. package/src/contexts/payments/OverviewContext.tsx +174 -0
  13. package/src/contexts/payments/PaymentsContext.tsx +132 -0
  14. package/src/contexts/payments/README.md +201 -0
  15. package/src/contexts/payments/RootPaymentsContext.tsx +68 -0
  16. package/src/contexts/payments/index.ts +50 -0
  17. package/src/index.ts +4 -1
  18. package/src/layouts/AppLayout/AppLayout.tsx +20 -10
  19. package/src/layouts/PaymentsLayout/PaymentsLayout.tsx +1 -1
  20. package/src/layouts/PaymentsLayout/components/CreatePaymentDialog.tsx +1 -1
  21. package/src/layouts/PaymentsLayout/components/PaymentDetailsDialog.tsx +3 -22
  22. package/src/layouts/PaymentsLayout/views/overview/components/BalanceCard.tsx +1 -1
  23. package/src/layouts/PaymentsLayout/views/overview/components/RecentPayments.tsx +1 -1
  24. package/src/layouts/PaymentsLayout/views/transactions/components/TransactionsList.tsx +1 -1
  25. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +1 -1
  26. package/src/layouts/SupportLayout/SupportLayout.tsx +1 -1
  27. package/src/layouts/SupportLayout/components/MessageList.tsx +1 -1
  28. package/src/layouts/SupportLayout/components/TicketCard.tsx +1 -1
  29. package/src/layouts/SupportLayout/components/TicketList.tsx +1 -1
  30. package/src/layouts/SupportLayout/context/SupportLayoutContext.tsx +1 -1
  31. package/src/layouts/SupportLayout/index.ts +2 -0
  32. package/src/layouts/SupportLayout/types.ts +2 -4
  33. package/src/snippets/Chat/ChatWidget.tsx +1 -1
  34. package/src/snippets/Chat/components/MessageList.tsx +1 -1
  35. package/src/snippets/Chat/components/SessionList.tsx +2 -2
  36. package/src/snippets/Chat/index.tsx +1 -1
  37. package/src/snippets/Chat/types.ts +7 -5
  38. package/src/snippets/ContactForm/ContactForm.tsx +20 -8
  39. package/src/snippets/McpChat/components/AIChatWidget.tsx +268 -0
  40. package/src/snippets/McpChat/components/ChatMessages.tsx +151 -0
  41. package/src/snippets/McpChat/components/ChatPanel.tsx +126 -0
  42. package/src/snippets/McpChat/components/ChatSidebar.tsx +119 -0
  43. package/src/snippets/McpChat/components/ChatWidget.tsx +134 -0
  44. package/src/snippets/McpChat/components/MessageBubble.tsx +125 -0
  45. package/src/snippets/McpChat/components/MessageInput.tsx +139 -0
  46. package/src/snippets/McpChat/components/index.ts +22 -0
  47. package/src/snippets/McpChat/config.ts +35 -0
  48. package/src/snippets/McpChat/context/AIChatContext.tsx +245 -0
  49. package/src/snippets/McpChat/context/ChatContext.tsx +350 -0
  50. package/src/snippets/McpChat/context/index.ts +7 -0
  51. package/src/snippets/McpChat/hooks/index.ts +5 -0
  52. package/src/snippets/McpChat/hooks/useAIChat.ts +487 -0
  53. package/src/snippets/McpChat/hooks/useChatLayout.ts +329 -0
  54. package/src/snippets/McpChat/index.ts +76 -0
  55. package/src/snippets/McpChat/types.ts +141 -0
  56. package/src/snippets/index.ts +32 -0
  57. package/src/utils/index.ts +0 -1
  58. package/src/utils/og-image.ts +0 -169
@@ -0,0 +1,151 @@
1
+ 'use client';
2
+
3
+ import React, { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
4
+ import { ScrollArea, Button, type ScrollAreaHandle } from '@djangocfg/ui';
5
+ import { Bot, MessageSquare, StopCircle } from 'lucide-react';
6
+ import { MessageBubble } from './MessageBubble';
7
+ import type { AIChatMessage } from '../types';
8
+
9
+ /**
10
+ * ChatMessages imperative handle
11
+ */
12
+ export interface ChatMessagesHandle {
13
+ scrollToBottom: (instant?: boolean) => void;
14
+ }
15
+
16
+ export interface ChatMessagesProps {
17
+ /** Messages to display */
18
+ messages: AIChatMessage[];
19
+ /** Whether loading/streaming is in progress */
20
+ isLoading: boolean;
21
+ /** Greeting to show when no messages */
22
+ greeting?: string;
23
+ /** Callback to stop streaming */
24
+ onStopStreaming?: () => void;
25
+ /** Use compact layout (smaller bubbles) */
26
+ isCompact?: boolean;
27
+ /** Use larger icon for greeting (sidebar mode) */
28
+ largeGreetingIcon?: boolean;
29
+ /** Custom greeting icon */
30
+ greetingIcon?: 'bot' | 'message';
31
+ /** Custom greeting title */
32
+ greetingTitle?: string;
33
+ }
34
+
35
+ /**
36
+ * ChatMessages - Shared component for displaying chat messages
37
+ *
38
+ * Handles:
39
+ * - Messages list rendering
40
+ * - Greeting display when empty
41
+ * - Loading indicator with stop button
42
+ * - Auto-scroll on new messages
43
+ * - Initial scroll on history load
44
+ */
45
+ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
46
+ (
47
+ {
48
+ messages,
49
+ isLoading,
50
+ greeting,
51
+ onStopStreaming,
52
+ isCompact = false,
53
+ largeGreetingIcon = false,
54
+ greetingIcon = 'bot',
55
+ greetingTitle,
56
+ },
57
+ ref
58
+ ) => {
59
+ const scrollAreaRef = useRef<ScrollAreaHandle>(null);
60
+ const hasScrolledOnLoad = useRef(false);
61
+
62
+ // Scroll to bottom helper
63
+ const scrollToBottom = useCallback((instant = false) => {
64
+ if (scrollAreaRef.current) {
65
+ scrollAreaRef.current.scrollToBottom(instant ? 'instant' : 'smooth');
66
+ }
67
+ }, []);
68
+
69
+ // Expose scrollToBottom via ref
70
+ useImperativeHandle(ref, () => ({
71
+ scrollToBottom,
72
+ }), [scrollToBottom]);
73
+
74
+ // Initial scroll when history loads (instant, no animation)
75
+ useEffect(() => {
76
+ if (!isLoading && messages.length > 0 && !hasScrolledOnLoad.current) {
77
+ requestAnimationFrame(() => {
78
+ scrollToBottom(true);
79
+ hasScrolledOnLoad.current = true;
80
+ });
81
+ }
82
+ }, [isLoading, messages.length, scrollToBottom]);
83
+
84
+ // Scroll to bottom on new messages (smooth animation)
85
+ useEffect(() => {
86
+ if (hasScrolledOnLoad.current && messages.length > 0) {
87
+ scrollToBottom(false);
88
+ }
89
+ }, [messages, scrollToBottom]);
90
+
91
+ const GreetingIcon = greetingIcon === 'message' ? MessageSquare : Bot;
92
+ const iconSize = largeGreetingIcon ? { container: '64px', icon: 'h-8 w-8' } : { container: '48px', icon: 'h-6 w-6' };
93
+ const padding = largeGreetingIcon ? 'py-12' : 'py-8';
94
+
95
+ return (
96
+ <ScrollArea ref={scrollAreaRef} className="h-full">
97
+ <div className={`${isCompact ? 'p-3' : 'p-4'} space-y-4`}>
98
+ {/* Greeting */}
99
+ {messages.length === 0 && greeting && (
100
+ <div className={`text-center ${padding}`}>
101
+ <div
102
+ className="mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center"
103
+ style={{ width: iconSize.container, height: iconSize.container }}
104
+ >
105
+ <GreetingIcon className={`${iconSize.icon} text-primary`} />
106
+ </div>
107
+ {greetingTitle && (
108
+ <h4 className="font-medium mb-2">{greetingTitle}</h4>
109
+ )}
110
+ <p className={`text-sm text-muted-foreground ${largeGreetingIcon ? 'max-w-[300px]' : 'max-w-[280px]'} mx-auto`}>
111
+ {greeting}
112
+ </p>
113
+ </div>
114
+ )}
115
+
116
+ {/* Messages */}
117
+ {messages.map((message) => (
118
+ <MessageBubble key={message.id} message={message} isCompact={isCompact} />
119
+ ))}
120
+
121
+ {/* Loading indicator with stop button */}
122
+ {isLoading && messages.length > 0 && (
123
+ <div className="flex items-center justify-between text-muted-foreground text-sm">
124
+ <div className="flex items-center gap-2">
125
+ <div className="flex gap-1">
126
+ <span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span>
127
+ <span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span>
128
+ <span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span>
129
+ </div>
130
+ <span>Generating response...</span>
131
+ </div>
132
+ {onStopStreaming && (
133
+ <Button
134
+ variant="ghost"
135
+ size="sm"
136
+ onClick={onStopStreaming}
137
+ className="h-6 px-2 text-xs"
138
+ >
139
+ <StopCircle className="h-3 w-3 mr-1" />
140
+ Stop
141
+ </Button>
142
+ )}
143
+ </div>
144
+ )}
145
+ </div>
146
+ </ScrollArea>
147
+ );
148
+ }
149
+ );
150
+
151
+ ChatMessages.displayName = 'ChatMessages';
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Card, CardHeader, CardContent, CardFooter, Button } from '@djangocfg/ui';
5
+ import { X, Minimize2, PanelRight, Bot } from 'lucide-react';
6
+ import type { ChatDisplayMode, AIChatMessage } from '../types';
7
+ import { ChatMessages } from './ChatMessages';
8
+ import { AIMessageInput } from './MessageInput';
9
+
10
+ export interface ChatPanelProps {
11
+ messages: AIChatMessage[];
12
+ isLoading: boolean;
13
+ onSendMessage: (content: string) => void;
14
+ onClose?: () => void;
15
+ onMinimize?: () => void;
16
+ onModeChange?: (mode: ChatDisplayMode) => void;
17
+ onStopStreaming?: () => void;
18
+ isMinimized?: boolean;
19
+ isMobile?: boolean;
20
+ title?: string;
21
+ placeholder?: string;
22
+ greeting?: string;
23
+ }
24
+
25
+ export const ChatPanel = React.memo<ChatPanelProps>(
26
+ ({
27
+ messages,
28
+ isLoading,
29
+ onSendMessage,
30
+ onClose,
31
+ onMinimize,
32
+ onModeChange,
33
+ onStopStreaming,
34
+ isMinimized = false,
35
+ isMobile = false,
36
+ title = 'DjangoCFG Assistant',
37
+ placeholder,
38
+ greeting,
39
+ }) => {
40
+ if (isMinimized) {
41
+ return (
42
+ <Button
43
+ onClick={onMinimize}
44
+ className="rounded-full shadow-lg"
45
+ style={{ width: '56px', height: '56px' }}
46
+ >
47
+ <Bot className="h-6 w-6" />
48
+ </Button>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <Card
54
+ className="flex flex-col shadow-2xl border-border/50"
55
+ style={{
56
+ width: '380px',
57
+ height: '520px',
58
+ maxHeight: 'calc(100vh - 100px)',
59
+ }}
60
+ >
61
+ {/* Header */}
62
+ <CardHeader className="flex flex-row items-center justify-between p-3 border-b">
63
+ <div className="flex items-center gap-2">
64
+ <div
65
+ className="rounded-full bg-primary/10 flex items-center justify-center"
66
+ style={{ width: '32px', height: '32px' }}
67
+ >
68
+ <Bot className="h-4 w-4 text-primary" />
69
+ </div>
70
+ <div>
71
+ <h3 className="font-semibold text-sm">{title}</h3>
72
+ <p className="text-xs text-muted-foreground">AI-powered documentation assistant</p>
73
+ </div>
74
+ </div>
75
+ <div className="flex gap-1">
76
+ {/* Sidebar mode button - only on desktop */}
77
+ {onModeChange && !isMobile && (
78
+ <Button
79
+ variant="ghost"
80
+ size="icon"
81
+ className="h-8 w-8"
82
+ onClick={() => onModeChange('sidebar')}
83
+ title="Switch to sidebar mode"
84
+ >
85
+ <PanelRight className="h-4 w-4" />
86
+ </Button>
87
+ )}
88
+ {onMinimize && (
89
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onMinimize} title="Minimize">
90
+ <Minimize2 className="h-4 w-4" />
91
+ </Button>
92
+ )}
93
+ {onClose && (
94
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close">
95
+ <X className="h-4 w-4" />
96
+ </Button>
97
+ )}
98
+ </div>
99
+ </CardHeader>
100
+
101
+ {/* Messages */}
102
+ <CardContent className="flex-1 p-0 overflow-hidden">
103
+ <ChatMessages
104
+ messages={messages}
105
+ isLoading={isLoading}
106
+ greeting={greeting}
107
+ onStopStreaming={onStopStreaming}
108
+ isCompact
109
+ greetingIcon="bot"
110
+ />
111
+ </CardContent>
112
+
113
+ {/* Input */}
114
+ <CardFooter className="p-3 border-t">
115
+ <AIMessageInput
116
+ onSend={onSendMessage}
117
+ isLoading={isLoading}
118
+ placeholder={placeholder}
119
+ />
120
+ </CardFooter>
121
+ </Card>
122
+ );
123
+ }
124
+ );
125
+
126
+ ChatPanel.displayName = 'ChatPanel';
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect } from 'react';
4
+ import { Button } from '@djangocfg/ui';
5
+ import { X, PanelRightClose, Bot } from 'lucide-react';
6
+ import { ChatMessages } from './ChatMessages';
7
+ import { AIMessageInput } from './MessageInput';
8
+ import { useChatLayout } from '../hooks/useChatLayout';
9
+ import type { AIChatMessage, ChatDisplayMode } from '../types';
10
+
11
+ const SIDEBAR_WIDTH = 400;
12
+
13
+ export interface ChatSidebarProps {
14
+ messages: AIChatMessage[];
15
+ isLoading: boolean;
16
+ onSendMessage: (content: string) => void;
17
+ onClose?: () => void;
18
+ onModeChange?: (mode: ChatDisplayMode) => void;
19
+ onStopStreaming?: () => void;
20
+ title?: string;
21
+ placeholder?: string;
22
+ greeting?: string;
23
+ }
24
+
25
+ export const ChatSidebar = React.memo<ChatSidebarProps>(
26
+ ({
27
+ messages,
28
+ isLoading,
29
+ onSendMessage,
30
+ onClose,
31
+ onModeChange,
32
+ onStopStreaming,
33
+ title = 'DjangoCFG Assistant',
34
+ placeholder,
35
+ greeting,
36
+ }) => {
37
+ // Use the layout hook for content pushing
38
+ const { applyLayout, getSidebarStyles } = useChatLayout({
39
+ sidebarWidth: SIDEBAR_WIDTH,
40
+ });
41
+
42
+ // Apply sidebar layout on mount, reset on unmount
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ useEffect(() => {
45
+ applyLayout('sidebar');
46
+ return () => {
47
+ applyLayout('closed');
48
+ };
49
+ }, []);
50
+
51
+ const sidebarStyles = getSidebarStyles();
52
+
53
+ return (
54
+ <div
55
+ className="flex flex-col bg-background border-l border-border"
56
+ style={sidebarStyles}
57
+ data-chat-sidebar-panel
58
+ >
59
+ {/* Header - uses CSS variable for navbar height consistency */}
60
+ <div
61
+ className="flex items-center justify-between px-4 border-b border-border bg-muted/30"
62
+ style={{ height: 'var(--nextra-navbar-height, 64px)', minHeight: 'var(--nextra-navbar-height, 64px)' }}
63
+ >
64
+ <div className="flex items-center gap-2">
65
+ <div
66
+ className="rounded-full bg-primary/10 flex items-center justify-center"
67
+ style={{ width: '32px', height: '32px' }}
68
+ >
69
+ <Bot className="h-4 w-4 text-primary" />
70
+ </div>
71
+ <div>
72
+ <h3 className="font-semibold text-sm">{title}</h3>
73
+ <p className="text-xs text-muted-foreground">Documentation assistant</p>
74
+ </div>
75
+ </div>
76
+ <div className="flex gap-1">
77
+ {onModeChange && (
78
+ <Button
79
+ variant="ghost"
80
+ size="icon"
81
+ className="h-8 w-8"
82
+ onClick={() => onModeChange('floating')}
83
+ title="Switch to floating mode"
84
+ >
85
+ <PanelRightClose className="h-4 w-4" />
86
+ </Button>
87
+ )}
88
+ {onClose && (
89
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close chat">
90
+ <X className="h-4 w-4" />
91
+ </Button>
92
+ )}
93
+ </div>
94
+ </div>
95
+
96
+ {/* Messages */}
97
+ <div className="flex-1 overflow-hidden">
98
+ <ChatMessages
99
+ messages={messages}
100
+ isLoading={isLoading}
101
+ greeting={greeting}
102
+ onStopStreaming={onStopStreaming}
103
+ isCompact={false}
104
+ largeGreetingIcon
105
+ greetingIcon="message"
106
+ greetingTitle="How can I help?"
107
+ />
108
+ </div>
109
+
110
+ {/* Input */}
111
+ <div className="p-4 border-t border-border bg-muted/30">
112
+ <AIMessageInput onSend={onSendMessage} isLoading={isLoading} placeholder={placeholder} />
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+ );
118
+
119
+ ChatSidebar.displayName = 'ChatSidebar';
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Button, Portal } from '@djangocfg/ui';
5
+ import { MessageCircle } from 'lucide-react';
6
+ import { ChatPanel } from './ChatPanel';
7
+ import { ChatSidebar } from './ChatSidebar';
8
+ import { useChatContext, useChatContextOptional, ChatProvider } from '../context';
9
+ import { useChatLayout } from '../hooks/useChatLayout';
10
+ import type { ChatWidgetConfig } from '../types';
11
+
12
+ export interface ChatWidgetProps extends ChatWidgetConfig {
13
+ /** Custom class name for the container */
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * Internal chat widget that uses context
19
+ */
20
+ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) => {
21
+ const {
22
+ messages,
23
+ isLoading,
24
+ isMinimized,
25
+ config,
26
+ displayMode,
27
+ isMobile,
28
+ sendMessage,
29
+ openChat,
30
+ closeChat,
31
+ toggleMinimize,
32
+ setDisplayMode,
33
+ } = useChatContext();
34
+
35
+ // Use layout hook for consistent positioning
36
+ const { getFabStyles, getFloatingStyles } = useChatLayout();
37
+
38
+ const position = config.position || 'bottom-right';
39
+ const fabStyles = getFabStyles(position);
40
+ const floatingStyles = getFloatingStyles(position);
41
+
42
+ // Mode: closed - just show FAB
43
+ if (displayMode === 'closed') {
44
+ return (
45
+ <Portal>
46
+ <div style={fabStyles} className={className || ''}>
47
+ <Button
48
+ onClick={openChat}
49
+ className="rounded-full shadow-lg hover:shadow-xl transition-shadow"
50
+ style={{ width: '56px', height: '56px' }}
51
+ >
52
+ <MessageCircle className="h-6 w-6" />
53
+ </Button>
54
+ </div>
55
+ </Portal>
56
+ );
57
+ }
58
+
59
+ // Mode: sidebar - full-height panel on the right (desktop only)
60
+ if (displayMode === 'sidebar') {
61
+ return (
62
+ <Portal>
63
+ <ChatSidebar
64
+ messages={messages}
65
+ isLoading={isLoading}
66
+ onSendMessage={sendMessage}
67
+ onClose={closeChat}
68
+ onModeChange={setDisplayMode}
69
+ title={config.title}
70
+ placeholder={config.placeholder}
71
+ greeting={config.greeting}
72
+ />
73
+ </Portal>
74
+ );
75
+ }
76
+
77
+ // Mode: floating - floating panel
78
+ return (
79
+ <Portal>
80
+ <div style={floatingStyles} className={className || ''}>
81
+ <ChatPanel
82
+ messages={messages}
83
+ isLoading={isLoading}
84
+ onSendMessage={sendMessage}
85
+ onClose={closeChat}
86
+ onMinimize={toggleMinimize}
87
+ onModeChange={setDisplayMode}
88
+ isMinimized={isMinimized}
89
+ isMobile={isMobile}
90
+ title={config.title}
91
+ placeholder={config.placeholder}
92
+ greeting={config.greeting}
93
+ />
94
+ </div>
95
+ </Portal>
96
+ );
97
+ };
98
+
99
+ /**
100
+ * ChatWidget component
101
+ *
102
+ * Can be used in two ways:
103
+ * 1. Standalone (wraps itself in ChatProvider)
104
+ * 2. Inside a ChatProvider (uses context directly)
105
+ */
106
+ export const ChatWidget: React.FC<ChatWidgetProps> = ({
107
+ apiEndpoint = '/api/chat',
108
+ title = 'DjangoCFG Assistant',
109
+ placeholder = 'Ask about DjangoCFG...',
110
+ greeting = "Hi! I'm your DjangoCFG documentation assistant. Ask me anything about configuration, features, or how to use the library.",
111
+ position = 'bottom-right',
112
+ variant = 'default',
113
+ className,
114
+ }) => {
115
+ // Check if we're inside a ChatProvider
116
+ const existingContext = useChatContextOptional();
117
+
118
+ // If already in context, use internal widget directly
119
+ if (existingContext) {
120
+ return <ChatWidgetInternal className={className} />;
121
+ }
122
+
123
+ // Otherwise, wrap in provider
124
+ return (
125
+ <ChatProvider
126
+ apiEndpoint={apiEndpoint}
127
+ config={{ title, placeholder, greeting, position, variant }}
128
+ >
129
+ <ChatWidgetInternal className={className} />
130
+ </ChatProvider>
131
+ );
132
+ };
133
+
134
+ ChatWidget.displayName = 'ChatWidget';
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Card, CardContent, Badge, MarkdownMessage } from '@djangocfg/ui';
5
+ import { User, Bot, ExternalLink, Loader2 } from 'lucide-react';
6
+ import type { AIChatMessage } from '../types';
7
+
8
+ export interface MessageBubbleProps {
9
+ message: AIChatMessage;
10
+ isCompact?: boolean;
11
+ }
12
+
13
+ function formatTime(date: Date): string {
14
+ return date.toLocaleTimeString('en-US', {
15
+ hour: '2-digit',
16
+ minute: '2-digit',
17
+ });
18
+ }
19
+
20
+ export const MessageBubble = React.memo<MessageBubbleProps>(
21
+ ({ message, isCompact = false }) => {
22
+ const isUser = message.role === 'user';
23
+ const isAssistant = message.role === 'assistant';
24
+
25
+ return (
26
+ <div
27
+ className={`flex gap-3 animate-in fade-in slide-in-from-bottom-2 duration-300 ${
28
+ isUser ? 'flex-row-reverse' : ''
29
+ }`}
30
+ >
31
+ {/* Avatar */}
32
+ <div
33
+ className={`flex-shrink-0 rounded-full flex items-center justify-center ${
34
+ isUser
35
+ ? 'bg-primary text-primary-foreground'
36
+ : 'bg-muted text-muted-foreground'
37
+ }`}
38
+ style={{
39
+ width: isCompact ? '28px' : '36px',
40
+ height: isCompact ? '28px' : '36px',
41
+ }}
42
+ >
43
+ {isUser ? (
44
+ <User className={isCompact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
45
+ ) : (
46
+ <Bot className={isCompact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
47
+ )}
48
+ </div>
49
+
50
+ {/* Message Content */}
51
+ <div className={`flex-1 min-w-0 ${isUser ? 'max-w-[80%] ml-auto' : 'max-w-[85%]'}`}>
52
+ {/* Header */}
53
+ <div className={`flex items-baseline gap-2 mb-1 ${isUser ? 'justify-end' : ''}`}>
54
+ <span className={`font-medium ${isCompact ? 'text-xs' : 'text-sm'}`}>
55
+ {isUser ? 'You' : 'DjangoCFG Assistant'}
56
+ </span>
57
+ <span className="text-xs text-muted-foreground">
58
+ {formatTime(message.timestamp)}
59
+ </span>
60
+ </div>
61
+
62
+ {/* Message Bubble */}
63
+ <Card
64
+ className={`transition-all duration-200 ${
65
+ isUser
66
+ ? 'bg-primary text-primary-foreground ml-auto'
67
+ : 'bg-muted'
68
+ }`}
69
+ >
70
+ <CardContent className={isCompact ? 'p-2' : 'p-3'}>
71
+ {/* Message Text */}
72
+ <div className={`${isCompact ? 'text-xs' : 'text-sm'}`}>
73
+ {isUser ? (
74
+ <span className="whitespace-pre-wrap">{message.content}</span>
75
+ ) : message.isStreaming ? (
76
+ // During streaming - show plain text to avoid parsing errors
77
+ <span className="whitespace-pre-wrap">
78
+ {message.content}
79
+ <Loader2 className="inline-block ml-1 h-3 w-3 animate-spin" />
80
+ </span>
81
+ ) : (
82
+ // Streaming complete - render with markdown formatting
83
+ <MarkdownMessage
84
+ content={message.content}
85
+ isUser={isUser}
86
+ className="prose-sm"
87
+ />
88
+ )}
89
+ </div>
90
+
91
+ {/* Sources */}
92
+ {isAssistant && message.sources && message.sources.length > 0 && (
93
+ <div className="mt-3 pt-2 border-t border-border/50">
94
+ <p className="text-xs text-muted-foreground mb-1.5">Related docs:</p>
95
+ <div className="flex flex-wrap gap-1.5">
96
+ {message.sources.slice(0, 3).map((source, idx) => (
97
+ <a
98
+ key={idx}
99
+ href={source.url || source.path}
100
+ target="_blank"
101
+ rel="noopener noreferrer"
102
+ className="inline-block"
103
+ >
104
+ <Badge
105
+ variant="outline"
106
+ className="text-xs gap-1 hover:bg-primary/10 transition-colors cursor-pointer"
107
+ >
108
+ {source.title}
109
+ {source.section && ` - ${source.section}`}
110
+ <ExternalLink className="h-2.5 w-2.5" />
111
+ </Badge>
112
+ </a>
113
+ ))}
114
+ </div>
115
+ </div>
116
+ )}
117
+ </CardContent>
118
+ </Card>
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+ );
124
+
125
+ MessageBubble.displayName = 'MessageBubble';