@djangocfg/ext-support 1.0.0

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 (65) hide show
  1. package/README.md +233 -0
  2. package/dist/chunk-AZ4LWZB7.js +2630 -0
  3. package/dist/hooks.cjs +2716 -0
  4. package/dist/hooks.d.cts +255 -0
  5. package/dist/hooks.d.ts +255 -0
  6. package/dist/hooks.js +1 -0
  7. package/dist/index.cjs +2693 -0
  8. package/dist/index.d.cts +1392 -0
  9. package/dist/index.d.ts +1392 -0
  10. package/dist/index.js +1 -0
  11. package/package.json +80 -0
  12. package/src/api/generated/ext_support/_utils/fetchers/ext_support__support.ts +642 -0
  13. package/src/api/generated/ext_support/_utils/fetchers/index.ts +28 -0
  14. package/src/api/generated/ext_support/_utils/hooks/ext_support__support.ts +237 -0
  15. package/src/api/generated/ext_support/_utils/hooks/index.ts +28 -0
  16. package/src/api/generated/ext_support/_utils/schemas/Message.schema.ts +21 -0
  17. package/src/api/generated/ext_support/_utils/schemas/MessageCreate.schema.ts +15 -0
  18. package/src/api/generated/ext_support/_utils/schemas/MessageCreateRequest.schema.ts +15 -0
  19. package/src/api/generated/ext_support/_utils/schemas/MessageRequest.schema.ts +15 -0
  20. package/src/api/generated/ext_support/_utils/schemas/PaginatedMessageList.schema.ts +24 -0
  21. package/src/api/generated/ext_support/_utils/schemas/PaginatedTicketList.schema.ts +24 -0
  22. package/src/api/generated/ext_support/_utils/schemas/PatchedMessageRequest.schema.ts +15 -0
  23. package/src/api/generated/ext_support/_utils/schemas/PatchedTicketRequest.schema.ts +18 -0
  24. package/src/api/generated/ext_support/_utils/schemas/Sender.schema.ts +21 -0
  25. package/src/api/generated/ext_support/_utils/schemas/Ticket.schema.ts +21 -0
  26. package/src/api/generated/ext_support/_utils/schemas/TicketRequest.schema.ts +18 -0
  27. package/src/api/generated/ext_support/_utils/schemas/index.ts +29 -0
  28. package/src/api/generated/ext_support/api-instance.ts +131 -0
  29. package/src/api/generated/ext_support/client.ts +301 -0
  30. package/src/api/generated/ext_support/enums.ts +45 -0
  31. package/src/api/generated/ext_support/errors.ts +116 -0
  32. package/src/api/generated/ext_support/ext_support__support/client.ts +151 -0
  33. package/src/api/generated/ext_support/ext_support__support/index.ts +2 -0
  34. package/src/api/generated/ext_support/ext_support__support/models.ts +165 -0
  35. package/src/api/generated/ext_support/http.ts +103 -0
  36. package/src/api/generated/ext_support/index.ts +273 -0
  37. package/src/api/generated/ext_support/logger.ts +259 -0
  38. package/src/api/generated/ext_support/retry.ts +175 -0
  39. package/src/api/generated/ext_support/schema.json +1049 -0
  40. package/src/api/generated/ext_support/storage.ts +161 -0
  41. package/src/api/generated/ext_support/validation-events.ts +133 -0
  42. package/src/api/index.ts +9 -0
  43. package/src/config.ts +20 -0
  44. package/src/contexts/SupportContext.tsx +250 -0
  45. package/src/contexts/SupportExtensionProvider.tsx +38 -0
  46. package/src/contexts/types.ts +26 -0
  47. package/src/hooks/index.ts +33 -0
  48. package/src/index.ts +39 -0
  49. package/src/layouts/SupportLayout/README.md +91 -0
  50. package/src/layouts/SupportLayout/SupportLayout.tsx +179 -0
  51. package/src/layouts/SupportLayout/components/CreateTicketDialog.tsx +155 -0
  52. package/src/layouts/SupportLayout/components/MessageInput.tsx +92 -0
  53. package/src/layouts/SupportLayout/components/MessageList.tsx +312 -0
  54. package/src/layouts/SupportLayout/components/TicketCard.tsx +96 -0
  55. package/src/layouts/SupportLayout/components/TicketList.tsx +153 -0
  56. package/src/layouts/SupportLayout/components/index.ts +6 -0
  57. package/src/layouts/SupportLayout/context/SupportLayoutContext.tsx +258 -0
  58. package/src/layouts/SupportLayout/context/index.ts +2 -0
  59. package/src/layouts/SupportLayout/events.ts +33 -0
  60. package/src/layouts/SupportLayout/hooks/index.ts +2 -0
  61. package/src/layouts/SupportLayout/hooks/useInfiniteMessages.ts +115 -0
  62. package/src/layouts/SupportLayout/hooks/useInfiniteTickets.ts +88 -0
  63. package/src/layouts/SupportLayout/index.ts +6 -0
  64. package/src/layouts/SupportLayout/types.ts +21 -0
  65. package/src/utils/logger.ts +14 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Message List Component
3
+ * Displays messages in a ticket conversation with infinite scroll
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useEffect, useRef, useCallback } from 'react';
9
+ import { ScrollArea, Skeleton, Avatar, AvatarFallback, AvatarImage, Card, CardContent, Button } from '@djangocfg/ui-nextjs';
10
+ import { MessageSquare, Loader2, User, Headphones } from 'lucide-react';
11
+ import { useSupportLayoutContext } from '../context';
12
+ import { useInfiniteMessages } from '../hooks';
13
+ import { useAuth } from '@djangocfg/api/auth';
14
+ import type { Message } from '../../../api/generated/ext_support/_utils/schemas';
15
+
16
+ const formatTime = (date: string | null | undefined): string => {
17
+ if (!date) return '';
18
+ return new Date(date).toLocaleTimeString('en-US', {
19
+ hour: '2-digit',
20
+ minute: '2-digit',
21
+ });
22
+ };
23
+
24
+ const formatDate = (date: string | null | undefined): string => {
25
+ if (!date) return '';
26
+ return new Date(date).toLocaleDateString('en-US', {
27
+ year: 'numeric',
28
+ month: 'short',
29
+ day: 'numeric',
30
+ });
31
+ };
32
+
33
+ interface MessageBubbleProps {
34
+ message: Message;
35
+ isFromUser: boolean;
36
+ currentUser: any;
37
+ }
38
+
39
+ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, isFromUser, currentUser }) => {
40
+ const sender = message.sender;
41
+
42
+ return (
43
+ <div
44
+ className={`flex gap-3 ${isFromUser ? 'justify-end' : 'justify-start'}
45
+ animate-in fade-in slide-in-from-bottom-2 duration-300`}
46
+ >
47
+ {/* Support Avatar (left side) */}
48
+ {!isFromUser && (
49
+ <Avatar className="h-8 w-8 shrink-0">
50
+ {sender?.avatar ? (
51
+ <AvatarImage src={sender.avatar} alt={sender.display_username || 'Support'} />
52
+ ) : (
53
+ <AvatarFallback className="bg-primary text-primary-foreground">
54
+ {sender?.is_staff ? (
55
+ <Headphones className="h-4 w-4" />
56
+ ) : (
57
+ sender?.display_username?.charAt(0)?.toUpperCase() ||
58
+ sender?.initials ||
59
+ 'S'
60
+ )}
61
+ </AvatarFallback>
62
+ )}
63
+ </Avatar>
64
+ )}
65
+
66
+ {/* Message Content */}
67
+ <div className={`flex flex-col gap-1 flex-1 max-w-[80%] ${
68
+ isFromUser ? 'items-end' : 'items-start'
69
+ }`}>
70
+ {/* Sender name (for support messages) */}
71
+ {!isFromUser && sender && (
72
+ <span className="text-xs text-muted-foreground px-1">
73
+ {sender.display_username || sender.email || 'Support Team'}
74
+ {sender.is_staff && ' (Staff)'}
75
+ </span>
76
+ )}
77
+
78
+ {/* Message Bubble */}
79
+ <Card
80
+ className={`${
81
+ isFromUser
82
+ ? 'bg-primary text-primary-foreground'
83
+ : 'bg-muted'
84
+ } transition-all duration-200 hover:shadow-md`}
85
+ >
86
+ <CardContent className="p-3">
87
+ <p className="text-sm whitespace-pre-wrap break-words">{message.text}</p>
88
+ </CardContent>
89
+ </Card>
90
+
91
+ {/* Timestamp */}
92
+ <span className="text-xs text-muted-foreground px-1">
93
+ {formatTime(message.created_at)}
94
+ </span>
95
+ </div>
96
+
97
+ {/* User Avatar (right side) */}
98
+ {isFromUser && (
99
+ <Avatar className="h-8 w-8 shrink-0">
100
+ {currentUser?.avatar ? (
101
+ <AvatarImage src={currentUser.avatar} alt={currentUser.display_username || currentUser.email || 'You'} />
102
+ ) : (
103
+ <AvatarFallback className="bg-primary/10 text-primary font-semibold">
104
+ {currentUser?.display_username?.charAt(0)?.toUpperCase() ||
105
+ currentUser?.email?.charAt(0)?.toUpperCase() ||
106
+ currentUser?.initials ||
107
+ <User className="h-4 w-4" />}
108
+ </AvatarFallback>
109
+ )}
110
+ </Avatar>
111
+ )}
112
+ </div>
113
+ );
114
+ };
115
+
116
+ export const MessageList: React.FC = () => {
117
+ const { selectedTicket } = useSupportLayoutContext();
118
+ const { user } = useAuth();
119
+
120
+ const {
121
+ messages,
122
+ isLoading,
123
+ isLoadingMore,
124
+ hasMore,
125
+ totalCount,
126
+ loadMore,
127
+ } = useInfiniteMessages(selectedTicket?.uuid || null);
128
+
129
+ const scrollRef = useRef<HTMLDivElement>(null);
130
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
131
+ const observerRef = useRef<IntersectionObserver | null>(null);
132
+ const loadMoreRef = useRef<HTMLDivElement>(null);
133
+ const firstRender = useRef(true);
134
+
135
+ // Set up intersection observer for infinite scroll at the top
136
+ useEffect(() => {
137
+ if (observerRef.current) {
138
+ observerRef.current.disconnect();
139
+ }
140
+
141
+ observerRef.current = new IntersectionObserver(
142
+ (entries) => {
143
+ if (entries[0]?.isIntersecting && hasMore && !isLoadingMore) {
144
+ loadMore();
145
+ }
146
+ },
147
+ { threshold: 0.1 }
148
+ );
149
+
150
+ if (loadMoreRef.current) {
151
+ observerRef.current.observe(loadMoreRef.current);
152
+ }
153
+
154
+ return () => {
155
+ if (observerRef.current) {
156
+ observerRef.current.disconnect();
157
+ }
158
+ };
159
+ }, [hasMore, isLoadingMore, loadMore]);
160
+
161
+ // Auto-scroll to bottom on first load and new messages
162
+ useEffect(() => {
163
+ if (firstRender.current && messages.length > 0) {
164
+ // Scroll to bottom on first render
165
+ const scrollContainer = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
166
+ if (scrollContainer) {
167
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
168
+ }
169
+ firstRender.current = false;
170
+ }
171
+ }, [messages]);
172
+
173
+ // Handle scroll position when loading older messages
174
+ const handleLoadMore = useCallback(() => {
175
+ const scrollContainer = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
176
+ const previousHeight = scrollContainer?.scrollHeight || 0;
177
+
178
+ loadMore();
179
+
180
+ // Restore scroll position after loading
181
+ setTimeout(() => {
182
+ if (scrollContainer) {
183
+ const newHeight = scrollContainer.scrollHeight;
184
+ scrollContainer.scrollTop = newHeight - previousHeight;
185
+ }
186
+ }, 100);
187
+ }, [loadMore]);
188
+
189
+ if (!selectedTicket) {
190
+ return (
191
+ <div className="flex flex-col items-center justify-center h-full p-8 text-center animate-in fade-in zoom-in-95 duration-300">
192
+ <MessageSquare className="h-16 w-16 text-muted-foreground mb-4 animate-bounce" />
193
+ <h3 className="text-lg font-semibold mb-2">No ticket selected</h3>
194
+ <p className="text-sm text-muted-foreground max-w-sm">
195
+ Select a ticket from the list to view the conversation
196
+ </p>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ if (isLoading) {
202
+ return (
203
+ <div className="p-6 space-y-4">
204
+ {[1, 2, 3].map((i) => (
205
+ <div
206
+ key={i}
207
+ className="flex gap-3 animate-pulse"
208
+ style={{ animationDelay: `${i * 100}ms` }}
209
+ >
210
+ <Skeleton className="h-8 w-8 rounded-full" />
211
+ <Skeleton className="h-16 flex-1 max-w-[70%]" />
212
+ </div>
213
+ ))}
214
+ </div>
215
+ );
216
+ }
217
+
218
+ if (!messages || messages.length === 0) {
219
+ return (
220
+ <div className="flex flex-col items-center justify-center h-full p-8 text-center animate-in fade-in zoom-in-95 duration-300">
221
+ <MessageSquare className="h-16 w-16 text-muted-foreground mb-4 animate-bounce" />
222
+ <h3 className="text-lg font-semibold mb-2">No messages yet</h3>
223
+ <p className="text-sm text-muted-foreground max-w-sm">
224
+ Start the conversation by sending a message below
225
+ </p>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ return (
231
+ <ScrollArea className="h-full bg-muted/50" viewportRef={scrollAreaRef}>
232
+ <div className="p-6 space-y-4" ref={scrollRef}>
233
+ {/* Load more trigger at the top */}
234
+ <div ref={loadMoreRef} className="h-2" />
235
+
236
+ {/* Loading indicator at the top */}
237
+ {isLoadingMore && (
238
+ <div className="flex justify-center py-4">
239
+ <div className="flex items-center gap-2 text-muted-foreground">
240
+ <Loader2 className="h-4 w-4 animate-spin" />
241
+ <span className="text-sm">Loading older messages...</span>
242
+ </div>
243
+ </div>
244
+ )}
245
+
246
+ {/* Manual load button if needed */}
247
+ {hasMore && !isLoadingMore && (
248
+ <div className="flex justify-center pt-2 pb-4">
249
+ <Button
250
+ variant="outline"
251
+ size="sm"
252
+ onClick={handleLoadMore}
253
+ className="text-xs"
254
+ >
255
+ Load older messages ({totalCount > 0 ? `${messages.length}/${totalCount}` : ''})
256
+ </Button>
257
+ </div>
258
+ )}
259
+
260
+ {/* Date separator for first message group */}
261
+ {messages.length > 0 && (
262
+ <div className="flex items-center gap-3 my-4">
263
+ <div className="flex-1 h-px bg-border" />
264
+ <span className="text-xs text-muted-foreground">
265
+ {formatDate(messages[0]?.created_at)}
266
+ </span>
267
+ <div className="flex-1 h-px bg-border" />
268
+ </div>
269
+ )}
270
+
271
+ {/* Messages */}
272
+ {messages.map((message, index) => {
273
+ // Check if message is from the current user
274
+ // Convert IDs to strings for consistent comparison
275
+ // Also check is_from_author flag when sender is the ticket creator
276
+ const isFromUser =
277
+ (message.sender?.id && user?.id && String(message.sender.id) === String(user.id)) ||
278
+ (message.sender?.email && user?.email && message.sender.email === user.email) ||
279
+ (message.is_from_author && selectedTicket?.user && user?.id &&
280
+ String(selectedTicket.user) === String(user.id));
281
+
282
+ // Show date separator if date changes
283
+ const previousMessage = index > 0 ? messages[index - 1] : null;
284
+ const showDateSeparator = previousMessage &&
285
+ new Date(previousMessage.created_at || '').toDateString() !==
286
+ new Date(message.created_at || '').toDateString();
287
+
288
+ return (
289
+ <React.Fragment key={message.uuid}>
290
+ {showDateSeparator && (
291
+ <div className="flex items-center gap-3 my-4">
292
+ <div className="flex-1 h-px bg-border" />
293
+ <span className="text-xs text-muted-foreground">
294
+ {formatDate(message.created_at)}
295
+ </span>
296
+ <div className="flex-1 h-px bg-border" />
297
+ </div>
298
+ )}
299
+ <div style={{ animationDelay: `${Math.min(index, 10) * 50}ms` }}>
300
+ <MessageBubble
301
+ message={message}
302
+ isFromUser={!!isFromUser}
303
+ currentUser={user}
304
+ />
305
+ </div>
306
+ </React.Fragment>
307
+ );
308
+ })}
309
+ </div>
310
+ </ScrollArea>
311
+ );
312
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Ticket Card Component
3
+ * Card for displaying a single ticket
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+ import { Badge, Card, CardContent, cn } from '@djangocfg/ui-nextjs';
10
+ import { Clock, MessageSquare } from 'lucide-react';
11
+ import type { Ticket } from '../../../api/generated/ext_support/_utils/schemas';
12
+
13
+ interface TicketCardProps {
14
+ ticket: Ticket;
15
+ isSelected: boolean;
16
+ onClick: () => void;
17
+ }
18
+
19
+ const getStatusBadgeVariant = (
20
+ status: string
21
+ ): 'default' | 'secondary' | 'destructive' | 'outline' => {
22
+ switch (status) {
23
+ case 'open':
24
+ return 'default';
25
+ case 'waiting_for_user':
26
+ return 'secondary';
27
+ case 'waiting_for_admin':
28
+ return 'outline';
29
+ case 'resolved':
30
+ return 'outline';
31
+ case 'closed':
32
+ return 'secondary';
33
+ default:
34
+ return 'default';
35
+ }
36
+ };
37
+
38
+ const formatRelativeTime = (date: string | null | undefined): string => {
39
+ if (!date) return 'N/A';
40
+
41
+ const now = new Date();
42
+ const messageDate = new Date(date);
43
+ const diffInSeconds = Math.floor((now.getTime() - messageDate.getTime()) / 1000);
44
+
45
+ if (diffInSeconds < 60) return 'Just now';
46
+ if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
47
+ if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
48
+ if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
49
+
50
+ return new Date(date).toLocaleDateString('en-US', {
51
+ year: 'numeric',
52
+ month: 'short',
53
+ day: 'numeric',
54
+ });
55
+ };
56
+
57
+ export const TicketCard: React.FC<TicketCardProps> = ({ ticket, isSelected, onClick }) => {
58
+ return (
59
+ <Card
60
+ className={cn(
61
+ 'cursor-pointer transition-all duration-200 ease-out',
62
+ 'hover:bg-accent/50 hover:shadow-md hover:scale-[1.02]',
63
+ 'active:scale-[0.98]',
64
+ isSelected && 'bg-accent border-primary shadow-sm'
65
+ )}
66
+ onClick={onClick}
67
+ >
68
+ <CardContent className="p-4">
69
+ <div className="flex items-start justify-between mb-2">
70
+ <h3 className="font-semibold text-sm line-clamp-2 flex-1">{ticket.subject}</h3>
71
+ {(ticket.unanswered_messages_count || 0) > 0 && (
72
+ <Badge
73
+ variant="destructive"
74
+ className="ml-2 shrink-0 animate-pulse"
75
+ >
76
+ {ticket.unanswered_messages_count}
77
+ </Badge>
78
+ )}
79
+ </div>
80
+
81
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
82
+ <div className="flex items-center gap-3">
83
+ <Badge variant={getStatusBadgeVariant(ticket.status || 'open')} className="text-xs">
84
+ {ticket.status || 'open'}
85
+ </Badge>
86
+ <div className="flex items-center gap-1">
87
+ <Clock className="h-3 w-3" />
88
+ <span>{formatRelativeTime(ticket.created_at)}</span>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </CardContent>
93
+ </Card>
94
+ );
95
+ };
96
+
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Ticket List Component
3
+ * Displays a list of support tickets with infinite scroll
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { useEffect, useRef } from 'react';
9
+ import { ScrollArea, Skeleton, Button } from '@djangocfg/ui-nextjs';
10
+ import { TicketCard } from './TicketCard';
11
+ import { useSupportLayoutContext } from '../context';
12
+ import { MessageSquare, Loader2 } from 'lucide-react';
13
+ import { useInfiniteTickets } from '../hooks';
14
+ import { SUPPORT_LAYOUT_EVENTS } from '../events';
15
+
16
+ export const TicketList: React.FC = () => {
17
+ const { selectedTicket, selectTicket } = useSupportLayoutContext();
18
+ const {
19
+ tickets,
20
+ isLoading,
21
+ isLoadingMore,
22
+ hasMore,
23
+ loadMore,
24
+ totalCount,
25
+ refresh
26
+ } = useInfiniteTickets();
27
+
28
+ const scrollRef = useRef<HTMLDivElement>(null);
29
+ const observerRef = useRef<IntersectionObserver | null>(null);
30
+ const loadMoreRef = useRef<HTMLDivElement>(null);
31
+
32
+ // Listen for ticket creation events to refresh the list
33
+ useEffect(() => {
34
+ const handleTicketCreated = () => {
35
+ // Refresh the tickets list when a new ticket is created
36
+ refresh();
37
+ };
38
+
39
+ window.addEventListener(SUPPORT_LAYOUT_EVENTS.TICKET_CREATED, handleTicketCreated);
40
+
41
+ return () => {
42
+ window.removeEventListener(SUPPORT_LAYOUT_EVENTS.TICKET_CREATED, handleTicketCreated);
43
+ };
44
+ }, [refresh]);
45
+
46
+ // Set up intersection observer for infinite scroll
47
+ useEffect(() => {
48
+ if (observerRef.current) {
49
+ observerRef.current.disconnect();
50
+ }
51
+
52
+ observerRef.current = new IntersectionObserver(
53
+ (entries) => {
54
+ if (entries[0]?.isIntersecting && hasMore && !isLoadingMore) {
55
+ loadMore();
56
+ }
57
+ },
58
+ { threshold: 0.1 }
59
+ );
60
+
61
+ if (loadMoreRef.current) {
62
+ observerRef.current.observe(loadMoreRef.current);
63
+ }
64
+
65
+ return () => {
66
+ if (observerRef.current) {
67
+ observerRef.current.disconnect();
68
+ }
69
+ };
70
+ }, [hasMore, isLoadingMore, loadMore]);
71
+
72
+ if (isLoading) {
73
+ return (
74
+ <div className="p-4 space-y-2">
75
+ {[1, 2, 3, 4, 5].map((i) => (
76
+ <div key={i}>
77
+ <Skeleton
78
+ className="h-24 w-full animate-pulse"
79
+ style={{ animationDelay: `${i * 100}ms` }}
80
+ />
81
+ </div>
82
+ ))}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ if (!tickets || tickets.length === 0) {
88
+ return (
89
+ <div className="flex flex-col items-center justify-center h-full p-8 text-center animate-in fade-in zoom-in-95 duration-300">
90
+ <MessageSquare className="h-16 w-16 text-muted-foreground mb-4 animate-bounce" />
91
+ <h3 className="text-lg font-semibold mb-2">No tickets yet</h3>
92
+ <p className="text-sm text-muted-foreground max-w-sm">
93
+ Create your first support ticket to get help from our team
94
+ </p>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ return (
100
+ <ScrollArea className="h-full" viewportRef={scrollRef}>
101
+ <div className="p-4 space-y-2">
102
+ {tickets.map((ticket, index) => (
103
+ <div
104
+ key={ticket.uuid}
105
+ className="animate-in fade-in slide-in-from-left-2 duration-300"
106
+ style={{ animationDelay: `${Math.min(index, 10) * 50}ms` }}
107
+ >
108
+ <TicketCard
109
+ ticket={ticket}
110
+ isSelected={selectedTicket?.uuid === ticket.uuid}
111
+ onClick={() => selectTicket(ticket)}
112
+ />
113
+ </div>
114
+ ))}
115
+
116
+ {/* Load more trigger */}
117
+ <div ref={loadMoreRef} className="h-2" />
118
+
119
+ {/* Loading indicator */}
120
+ {isLoadingMore && (
121
+ <div className="flex justify-center py-4">
122
+ <div className="flex items-center gap-2 text-muted-foreground">
123
+ <Loader2 className="h-4 w-4 animate-spin" />
124
+ <span className="text-sm">Loading more tickets...</span>
125
+ </div>
126
+ </div>
127
+ )}
128
+
129
+ {/* Manual load button if needed */}
130
+ {hasMore && !isLoadingMore && (
131
+ <div className="flex justify-center pt-2 pb-4">
132
+ <Button
133
+ variant="outline"
134
+ size="sm"
135
+ onClick={loadMore}
136
+ className="text-xs"
137
+ >
138
+ Load more ({totalCount > 0 ? `${tickets.length}/${totalCount}` : ''})
139
+ </Button>
140
+ </div>
141
+ )}
142
+
143
+ {/* End message */}
144
+ {!hasMore && tickets.length > 0 && (
145
+ <div className="text-center py-4 text-sm text-muted-foreground">
146
+ All {totalCount} tickets loaded
147
+ </div>
148
+ )}
149
+ </div>
150
+ </ScrollArea>
151
+ );
152
+ };
153
+
@@ -0,0 +1,6 @@
1
+ export * from './TicketCard';
2
+ export * from './TicketList';
3
+ export * from './MessageList';
4
+ export * from './MessageInput';
5
+ export * from './CreateTicketDialog';
6
+