Skip to content

Commit 58275eb

Browse files
committed
Merge branch 'dev'
2 parents f6b276d + e01add0 commit 58275eb

9 files changed

Lines changed: 194 additions & 75 deletions

File tree

web/src/actions/tickets.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,37 @@ export async function createTicket(subject: string, message: string) {
109109
}
110110
}
111111

112-
export async function getAllTickets() {
112+
export async function getAllTickets(page: number = 1, pageSize: number = 20, status?: 'OPEN' | 'ANSWERED' | 'CLOSED' | 'ALL') {
113113
try {
114114
if (!await isAdmin()) {
115115
throw new Error('Unauthorized');
116116
}
117117

118-
const tickets = await prisma.ticket.findMany({
119-
include: {
120-
user: true,
121-
messages: {
122-
orderBy: {
123-
createdAt: 'asc'
118+
const skip = (page - 1) * pageSize;
119+
const where = status && status !== 'ALL' ? { status } : {};
120+
121+
const [tickets, total] = await Promise.all([
122+
prisma.ticket.findMany({
123+
where,
124+
skip,
125+
take: pageSize,
126+
include: {
127+
user: true,
128+
messages: {
129+
orderBy: {
130+
createdAt: 'asc'
131+
}
124132
}
133+
},
134+
orderBy: {
135+
createdAt: 'desc'
125136
}
126-
},
127-
orderBy: {
128-
createdAt: 'desc'
129-
}
130-
});
137+
}),
138+
prisma.ticket.count({ where })
139+
]);
131140

132141
// Serialization
133-
return tickets.map((ticket: any) => ({
142+
const serializedTickets = tickets.map((ticket: any) => ({
134143
...ticket,
135144
createdAt: ticket.createdAt.toISOString(),
136145
updatedAt: ticket.updatedAt.toISOString(),
@@ -139,9 +148,32 @@ export async function getAllTickets() {
139148
createdAt: msg.createdAt.toISOString()
140149
}))
141150
}));
151+
152+
return {
153+
tickets: serializedTickets,
154+
total,
155+
hasMore: total > skip + tickets.length
156+
};
142157
} catch (error) {
143158
console.error('Error fetching all tickets:', error);
144-
return [];
159+
return { tickets: [], total: 0, hasMore: false };
160+
}
161+
}
162+
163+
export async function getTicketStats() {
164+
try {
165+
if (!await isAdmin()) return { OPEN: 0, ANSWERED: 0, CLOSED: 0 };
166+
167+
const [open, answered, closed] = await Promise.all([
168+
prisma.ticket.count({ where: { status: 'OPEN' } }),
169+
prisma.ticket.count({ where: { status: 'ANSWERED' } }),
170+
prisma.ticket.count({ where: { status: 'CLOSED' } })
171+
]);
172+
173+
return { OPEN: open, ANSWERED: answered, CLOSED: closed };
174+
} catch (error) {
175+
console.error('Error fetching ticket stats:', error);
176+
return { OPEN: 0, ANSWERED: 0, CLOSED: 0 };
145177
}
146178
}
147179

web/src/app/(main)/ask/page.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export default function AskPage() {
252252
value={newTicketSubject}
253253
onChange={(e) => setNewTicketSubject(e.target.value)}
254254
placeholder="e.g., Guidance on Meditation Practice"
255-
className="w-full bg-gray-50 border border-gray-100 rounded-2xl p-2.5 focus:ring-4 focus:ring-ochre/10 focus:border-ochre outline-none transition-all text-sm font-medium"
255+
className="w-full bg-gray-50 border border-gray-100 rounded-2xl p-2.5 focus:ring-4 focus:ring-ochre/10 focus:border-ochre outline-none transition-all text-base font-medium"
256256
/>
257257
</div>
258258
<div className="space-y-1.5">
@@ -263,7 +263,7 @@ export default function AskPage() {
263263
value={newTicketMessage}
264264
onChange={(e) => setNewTicketMessage(e.target.value)}
265265
placeholder="Please share your spiritual question or request for guidance in detail..."
266-
className="w-full bg-gray-50 border border-gray-100 rounded-2xl p-3.5 focus:ring-4 focus:ring-ochre/10 focus:border-ochre outline-none transition-all text-sm leading-relaxed resize-none font-medium"
266+
className="w-full bg-gray-50 border border-gray-100 rounded-2xl p-3.5 focus:ring-4 focus:ring-ochre/10 focus:border-ochre outline-none transition-all text-base leading-relaxed resize-none font-medium"
267267
/>
268268
</div>
269269
<button
@@ -314,15 +314,15 @@ export default function AskPage() {
314314
value={newTicketSubject}
315315
onChange={(e) => setNewTicketSubject(e.target.value)}
316316
placeholder="Topic of Spiritual Inquiry..."
317-
className="w-full bg-white border border-gray-100 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-ochre/20 focus:border-ochre outline-none font-medium"
317+
className="w-full bg-white border border-gray-100 rounded-xl px-3 py-2 text-base focus:ring-2 focus:ring-ochre/20 focus:border-ochre outline-none font-medium"
318318
/>
319319
<textarea
320320
required
321321
rows={3}
322322
value={newTicketMessage}
323323
onChange={(e) => setNewTicketMessage(e.target.value)}
324324
placeholder="Share your spiritual query or request guidance..."
325-
className="w-full bg-white border border-gray-100 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-ochre/20 focus:border-ochre outline-none font-medium resize-none"
325+
className="w-full bg-white border border-gray-100 rounded-xl px-3 py-2 text-base focus:ring-2 focus:ring-ochre/20 focus:border-ochre outline-none font-medium resize-none"
326326
/>
327327
<button
328328
type="submit"
@@ -506,7 +506,7 @@ export default function AskPage() {
506506
onChange={(e) => setFollowUpText(prev => ({ ...prev, [expandedTicketId!]: e.target.value }))}
507507
placeholder="Type your clarifying question here..."
508508
rows={2}
509-
className="w-full border-none bg-gray-50 rounded-xl p-3 text-sm font-medium focus:ring-2 focus:ring-ochre/20 outline-none resize-none mb-3"
509+
className="w-full border-none bg-gray-50 rounded-xl p-3 text-base font-medium focus:ring-2 focus:ring-ochre/20 outline-none resize-none mb-3"
510510
/>
511511
<div className="flex items-center justify-between gap-3">
512512
<button

web/src/app/admin/loading.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
2+
export default function AdminLoading() {
3+
return (
4+
<div className="flex flex-col items-center justify-center min-h-[70vh] space-y-8 animate-in fade-in duration-500">
5+
{/* Elegant Spinning Logo Container */}
6+
<div className="relative">
7+
{/* Outer decorative ring */}
8+
<div className="absolute -inset-4 border border-ochre/10 rounded-full animate-pulse" />
9+
10+
<div className="relative w-24 h-24 flex items-center justify-center">
11+
{/* The Spinning Border */}
12+
<div className="absolute inset-0 border-b-2 border-l-2 border-ochre rounded-full animate-spin" style={{ animationDuration: '1.2s' }} />
13+
14+
{/* The Inner Box / Logo placeholder */}
15+
<div className="w-16 h-16 bg-white rounded-2xl shadow-xl flex items-center justify-center border border-gray-100">
16+
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center animate-pulse">
17+
<div className="w-4 h-4 rounded-sm bg-ochre" />
18+
</div>
19+
</div>
20+
</div>
21+
</div>
22+
23+
{/* Admin-specific Loading Text */}
24+
<div className="text-center space-y-3">
25+
<div className="flex items-center justify-center space-x-2">
26+
<span className="w-1.5 h-1.5 bg-ochre rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
27+
<span className="w-1.5 h-1.5 bg-ochre rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
28+
<span className="w-1.5 h-1.5 bg-ochre rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
29+
</div>
30+
<h3 className="text-sm font-black text-gray-900 tracking-[0.3em] uppercase">
31+
Admin Console
32+
</h3>
33+
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
34+
Initializing System Data...
35+
</p>
36+
</div>
37+
</div>
38+
);
39+
}

web/src/app/admin/tickets/page.tsx

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

33
import { useState, useEffect } from 'react';
4-
import { getAllTickets, replyToTicket, closeTicket } from '@/actions/tickets';
5-
import { MessageCircle, Send, CheckCircle2, User, Clock, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
4+
import { getAllTickets, replyToTicket, closeTicket, getTicketStats } from '@/actions/tickets';
5+
import { MessageCircle, Send, CheckCircle2, User, Loader2 } from 'lucide-react';
66
import { Modal } from '@/components/common/Modal';
77
import { Notification, NotificationType } from '@/components/common/Notification';
88

@@ -30,25 +30,52 @@ type Ticket = {
3030
export default function AdminTicketsPage() {
3131
const [tickets, setTickets] = useState<Ticket[]>([]);
3232
const [isLoading, setIsLoading] = useState(true);
33+
const [page, setPage] = useState(1);
34+
const [hasMore, setHasMore] = useState(false);
35+
const [stats, setStats] = useState({ OPEN: 0, ANSWERED: 0, CLOSED: 0 });
36+
const [filter, setFilter] = useState<'OPEN' | 'ANSWERED' | 'CLOSED' | 'ALL'>('OPEN');
37+
3338
const [expandedTicketId, setExpandedTicketId] = useState<string | null>(null);
3439
const [replyText, setReplyText] = useState<{ [key: string]: string }>({});
3540
const [isSubmitting, setIsSubmitting] = useState<{ [key: string]: boolean }>({});
36-
const [filter, setFilter] = useState<'OPEN' | 'ANSWERED' | 'CLOSED' | 'ALL'>('OPEN');
3741

3842
// UI Feedback State
3943
const [notification, setNotification] = useState<{ message: string; type: NotificationType } | null>(null);
4044
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
4145
const [postReplyActionId, setPostReplyActionId] = useState<string | null>(null);
4246

4347
useEffect(() => {
44-
fetchTickets();
45-
}, []);
48+
setPage(1);
49+
fetchTickets(1, true);
50+
fetchStats();
51+
}, [filter]);
52+
53+
const fetchStats = async () => {
54+
const counts = await getTicketStats();
55+
setStats(counts);
56+
};
4657

47-
const fetchTickets = async () => {
58+
const fetchTickets = async (pageNum: number, isReset: boolean = false) => {
4859
setIsLoading(true);
49-
const data = await getAllTickets();
50-
setTickets(data as unknown as Ticket[]);
51-
setIsLoading(false);
60+
try {
61+
const result = await getAllTickets(pageNum, 20, filter);
62+
if (isReset) {
63+
setTickets(result.tickets as unknown as Ticket[]);
64+
} else {
65+
setTickets(prev => [...prev, ...(result.tickets as unknown as Ticket[])]);
66+
}
67+
setHasMore(result.hasMore);
68+
} catch (error) {
69+
console.error('Error fetching tickets:', error);
70+
} finally {
71+
setIsLoading(false);
72+
}
73+
};
74+
75+
const loadMore = () => {
76+
const nextPage = page + 1;
77+
setPage(nextPage);
78+
fetchTickets(nextPage);
5279
};
5380

5481
const handleReply = async (ticketId: string) => {
@@ -60,9 +87,10 @@ export default function AdminTicketsPage() {
6087

6188
if (result.success) {
6289
setReplyText(prev => ({ ...prev, [ticketId]: '' }));
63-
setExpandedTicketId(null); // Close the conversation modal
64-
setPostReplyActionId(ticketId); // Show post-reply options
65-
fetchTickets();
90+
setExpandedTicketId(null);
91+
setPostReplyActionId(ticketId);
92+
fetchTickets(1, true);
93+
fetchStats();
6694
} else {
6795
setNotification({ message: result.error || "Error sending guidance", type: 'error' });
6896
}
@@ -74,7 +102,8 @@ export default function AdminTicketsPage() {
74102
const result = await closeTicket(ticketId);
75103
if (result.success) {
76104
setNotification({ message: "Inquiry closed successfully", type: 'success' });
77-
fetchTickets();
105+
fetchTickets(1, true);
106+
fetchStats();
78107
} else {
79108
setNotification({ message: "Error closing inquiry", type: 'error' });
80109
}
@@ -105,7 +134,7 @@ export default function AdminTicketsPage() {
105134
: 'bg-white text-gray-400 border-gray-100 hover:bg-gray-50'
106135
}`}
107136
>
108-
<span className="flex-none">{tickets.filter(t => t.status === 'OPEN').length} New</span>
137+
<span className="flex-none">{stats.OPEN} New</span>
109138
</button>
110139
<button
111140
onClick={() => setFilter('ANSWERED')}
@@ -114,7 +143,7 @@ export default function AdminTicketsPage() {
114143
: 'bg-white text-gray-400 border-gray-100 hover:bg-gray-50'
115144
}`}
116145
>
117-
<span className="flex-none">{tickets.filter(t => t.status === 'ANSWERED').length} Answered</span>
146+
<span className="flex-none">{stats.ANSWERED} Answered</span>
118147
</button>
119148
<button
120149
onClick={() => setFilter('CLOSED')}
@@ -123,22 +152,21 @@ export default function AdminTicketsPage() {
123152
: 'bg-white text-gray-400 border-gray-100 hover:bg-gray-50'
124153
}`}
125154
>
126-
<span className="flex-none">Closed</span>
155+
<span className="flex-none">{stats.CLOSED} Closed</span>
127156
</button>
128157
</div>
129158
</div>
130159

131160
<div className="grid gap-4 w-full mt-6 md:mt-10">
132-
{tickets.filter(t => filter === 'ALL' || t.status === filter).length === 0 ? (
161+
{tickets.length === 0 ? (
133162
<div className="text-center py-20 bg-white rounded-3xl border border-dashed border-gray-200 shadow-sm animate-in fade-in duration-500 mx-1">
134163
<MessageCircle className="w-12 h-12 text-gray-200 mx-auto mb-4" />
135164
<h3 className="text-xl font-black text-gray-900 tracking-tight">Nothing here</h3>
136165
<p className="text-sm text-gray-400 font-medium">No inquiries matching this status.</p>
137166
</div>
138167
) : (
139-
tickets
140-
.filter(t => filter === 'ALL' || t.status === filter)
141-
.map((ticket) => (
168+
<>
169+
{tickets.map((ticket) => (
142170
<div
143171
key={ticket.id}
144172
onClick={() => setExpandedTicketId(ticket.id)}
@@ -172,11 +200,34 @@ export default function AdminTicketsPage() {
172200
</div>
173201
<div className="flex flex-col items-end gap-1 flex-none">
174202
<div className="bg-gray-100/50 group-hover:bg-ochre group-hover:text-white px-2 md:px-3 py-1 md:py-1.5 rounded-lg md:rounded-xl text-[8px] md:text-[10px] font-black text-gray-500 transition-all border border-gray-100/10 uppercase tracking-widest shadow-sm">
175-
{ticket.messages.length} <span className="hidden xs:inline">MSG</span>
203+
{ticket.messages.length} MSG
176204
</div>
177205
</div>
178206
</div>
179-
))
207+
))}
208+
209+
{hasMore && (
210+
<div className="flex justify-center pt-8 pb-12">
211+
<button
212+
onClick={(e) => {
213+
e.stopPropagation();
214+
loadMore();
215+
}}
216+
disabled={isLoading}
217+
className="bg-white text-ochre px-8 py-3 rounded-2xl font-black text-xs uppercase tracking-[0.2em] border border-ochre/20 hover:bg-orange-50 transition-all flex items-center shadow-sm disabled:opacity-50"
218+
>
219+
{isLoading ? (
220+
<>
221+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
222+
Loading...
223+
</>
224+
) : (
225+
"Explore More"
226+
)}
227+
</button>
228+
</div>
229+
)}
230+
</>
180231
)}
181232
</div>
182233

@@ -188,7 +239,6 @@ export default function AdminTicketsPage() {
188239
flush
189240
>
190241
<div className="flex flex-col h-[70vh]">
191-
{/* Thread Content */}
192242
<div className="flex-1 overflow-y-auto p-4 space-y-3 no-scrollbar bg-gray-50/50">
193243
{tickets.find(t => t.id === expandedTicketId)?.messages.map((msg, idx) => (
194244
<div key={idx} className={`flex ${msg.sender === 'USER' ? 'justify-start' : 'justify-end'}`}>
@@ -206,7 +256,6 @@ export default function AdminTicketsPage() {
206256
))}
207257
</div>
208258

209-
{/* Footer / Reply Area */}
210259
{expandedTicketId && tickets.find(t => t.id === expandedTicketId)?.status !== 'CLOSED' && (
211260
<div className="p-4 bg-white border-t border-gray-100 pb-8 md:pb-4">
212261
<textarea
@@ -278,7 +327,6 @@ export default function AdminTicketsPage() {
278327
</div>
279328
</Modal>
280329

281-
{/* Premium UI Components */}
282330
<Modal
283331
isOpen={!!confirmCloseId}
284332
onClose={() => setConfirmCloseId(null)}

0 commit comments

Comments
 (0)