@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.
- package/README.md +233 -0
- package/dist/chunk-AZ4LWZB7.js +2630 -0
- package/dist/hooks.cjs +2716 -0
- package/dist/hooks.d.cts +255 -0
- package/dist/hooks.d.ts +255 -0
- package/dist/hooks.js +1 -0
- package/dist/index.cjs +2693 -0
- package/dist/index.d.cts +1392 -0
- package/dist/index.d.ts +1392 -0
- package/dist/index.js +1 -0
- package/package.json +80 -0
- package/src/api/generated/ext_support/_utils/fetchers/ext_support__support.ts +642 -0
- package/src/api/generated/ext_support/_utils/fetchers/index.ts +28 -0
- package/src/api/generated/ext_support/_utils/hooks/ext_support__support.ts +237 -0
- package/src/api/generated/ext_support/_utils/hooks/index.ts +28 -0
- package/src/api/generated/ext_support/_utils/schemas/Message.schema.ts +21 -0
- package/src/api/generated/ext_support/_utils/schemas/MessageCreate.schema.ts +15 -0
- package/src/api/generated/ext_support/_utils/schemas/MessageCreateRequest.schema.ts +15 -0
- package/src/api/generated/ext_support/_utils/schemas/MessageRequest.schema.ts +15 -0
- package/src/api/generated/ext_support/_utils/schemas/PaginatedMessageList.schema.ts +24 -0
- package/src/api/generated/ext_support/_utils/schemas/PaginatedTicketList.schema.ts +24 -0
- package/src/api/generated/ext_support/_utils/schemas/PatchedMessageRequest.schema.ts +15 -0
- package/src/api/generated/ext_support/_utils/schemas/PatchedTicketRequest.schema.ts +18 -0
- package/src/api/generated/ext_support/_utils/schemas/Sender.schema.ts +21 -0
- package/src/api/generated/ext_support/_utils/schemas/Ticket.schema.ts +21 -0
- package/src/api/generated/ext_support/_utils/schemas/TicketRequest.schema.ts +18 -0
- package/src/api/generated/ext_support/_utils/schemas/index.ts +29 -0
- package/src/api/generated/ext_support/api-instance.ts +131 -0
- package/src/api/generated/ext_support/client.ts +301 -0
- package/src/api/generated/ext_support/enums.ts +45 -0
- package/src/api/generated/ext_support/errors.ts +116 -0
- package/src/api/generated/ext_support/ext_support__support/client.ts +151 -0
- package/src/api/generated/ext_support/ext_support__support/index.ts +2 -0
- package/src/api/generated/ext_support/ext_support__support/models.ts +165 -0
- package/src/api/generated/ext_support/http.ts +103 -0
- package/src/api/generated/ext_support/index.ts +273 -0
- package/src/api/generated/ext_support/logger.ts +259 -0
- package/src/api/generated/ext_support/retry.ts +175 -0
- package/src/api/generated/ext_support/schema.json +1049 -0
- package/src/api/generated/ext_support/storage.ts +161 -0
- package/src/api/generated/ext_support/validation-events.ts +133 -0
- package/src/api/index.ts +9 -0
- package/src/config.ts +20 -0
- package/src/contexts/SupportContext.tsx +250 -0
- package/src/contexts/SupportExtensionProvider.tsx +38 -0
- package/src/contexts/types.ts +26 -0
- package/src/hooks/index.ts +33 -0
- package/src/index.ts +39 -0
- package/src/layouts/SupportLayout/README.md +91 -0
- package/src/layouts/SupportLayout/SupportLayout.tsx +179 -0
- package/src/layouts/SupportLayout/components/CreateTicketDialog.tsx +155 -0
- package/src/layouts/SupportLayout/components/MessageInput.tsx +92 -0
- package/src/layouts/SupportLayout/components/MessageList.tsx +312 -0
- package/src/layouts/SupportLayout/components/TicketCard.tsx +96 -0
- package/src/layouts/SupportLayout/components/TicketList.tsx +153 -0
- package/src/layouts/SupportLayout/components/index.ts +6 -0
- package/src/layouts/SupportLayout/context/SupportLayoutContext.tsx +258 -0
- package/src/layouts/SupportLayout/context/index.ts +2 -0
- package/src/layouts/SupportLayout/events.ts +33 -0
- package/src/layouts/SupportLayout/hooks/index.ts +2 -0
- package/src/layouts/SupportLayout/hooks/useInfiniteMessages.ts +115 -0
- package/src/layouts/SupportLayout/hooks/useInfiniteTickets.ts +88 -0
- package/src/layouts/SupportLayout/index.ts +6 -0
- package/src/layouts/SupportLayout/types.ts +21 -0
- 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
|
+
|