Localize wallet and ticket pages.
Move the wallet page and all four ticket views (user list, ticket detail, staff queue, admin overview) onto the i18n dictionaries — statuses, priorities, departments, forms, stats, toasts and empty states — using locale-aware Link/router and locale-aware date formatting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import api from '@/lib/api';
|
||||
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types';
|
||||
import { ClipboardList, Wrench, Briefcase, User, Inbox } from 'lucide-react';
|
||||
@@ -21,6 +22,9 @@ const priorityColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function AdminTicketsPage() {
|
||||
const t = useT();
|
||||
const tk = t.dashboard.tickets;
|
||||
const locale = useLocale();
|
||||
const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>('');
|
||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
|
||||
|
||||
@@ -41,46 +45,46 @@ export default function AdminTicketsPage() {
|
||||
});
|
||||
|
||||
const formatResponseTime = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
|
||||
return `${Math.round(minutes / 1440)} days`;
|
||||
if (minutes < 60) return tk.minutes.replace('{n}', String(minutes));
|
||||
if (minutes < 1440) return tk.hours.replace('{n}', String(Math.round(minutes / 60)));
|
||||
return tk.days.replace('{n}', String(Math.round(minutes / 1440)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><ClipboardList className="w-6 h-6" /> All Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><ClipboardList className="w-6 h-6" /> {tk.allTickets}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{tk.allTicketsSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Total Tickets</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.totalTicketsStat}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTickets}</p>
|
||||
</div>
|
||||
<div className="card p-4 border-l-4 border-l-red-500">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.unanswered}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${stats.openTickets > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{stats.openTickets}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Need staff response</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{tk.needStaffResponse}</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.avgResponseTime}</p>
|
||||
<p className="text-2xl font-bold text-primary-600 mt-1">
|
||||
{stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">By Department</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.byDepartment}</p>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{Object.entries(stats.byDepartment).map(([dept, data]) => (
|
||||
<div key={dept} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600 capitalize flex items-center gap-1">{dept === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {dept}</span>
|
||||
<span className="text-gray-600 flex items-center gap-1">{dept === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {(tk.department as Record<string, string>)[dept] ?? dept}</span>
|
||||
<span className="font-medium">
|
||||
<span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span>
|
||||
<span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} {tk.unanswered}</span>
|
||||
<span className="text-gray-400"> / {data.total}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -93,7 +97,7 @@ export default function AdminTicketsPage() {
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-500 self-center">Department:</span>
|
||||
<span className="text-sm text-gray-500 self-center">{tk.departmentLabel}</span>
|
||||
{(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => (
|
||||
<button
|
||||
key={dept}
|
||||
@@ -104,12 +108,12 @@ export default function AdminTicketsPage() {
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{dept === '' ? 'All' : dept === 'technical' ? 'Technical' : 'Sales'}
|
||||
{dept === '' ? tk.all : (tk.department as Record<string, string>)[dept]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-500 self-center">Status:</span>
|
||||
<span className="text-sm text-gray-500 self-center">{tk.statusLabel}</span>
|
||||
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
@@ -120,7 +124,7 @@ export default function AdminTicketsPage() {
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{status === '' ? tk.all : (tk.status as Record<string, string>)[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -134,7 +138,7 @@ export default function AdminTicketsPage() {
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<Inbox className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets found</h3>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">{tk.noTicketsFound}</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -149,13 +153,13 @@ export default function AdminTicketsPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
{(tk.status as Record<string, string>)[ticket.status] ?? ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
{(tk.priority as Record<string, string>)[ticket.priority] ?? ticket.priority}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 flex items-center gap-1">
|
||||
{ticket.department === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {ticket.department === 'technical' ? 'Technical' : 'Sales'}
|
||||
{ticket.department === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {(tk.department as Record<string, string>)[ticket.department] ?? ticket.department}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
@@ -165,12 +169,12 @@ export default function AdminTicketsPage() {
|
||||
</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
<span>{tk.msg.replace('{n}', String(ticket.messages?.length || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
<span className="text-gray-400 text-sm rtl:rotate-180">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { Ticket, TicketStatus } from '@/types';
|
||||
@@ -22,12 +23,15 @@ const priorityColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function StaffTicketsPage() {
|
||||
const t = useT();
|
||||
const tk = t.dashboard.tickets;
|
||||
const locale = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
|
||||
|
||||
// Determine which department this staff member handles
|
||||
const department = user?.role === 'sales' ? 'sales' : 'technical';
|
||||
const departmentLabel = department === 'technical' ? 'Technical' : 'Sales';
|
||||
const departmentLabel = department === 'technical' ? tk.department.technical : tk.department.sales;
|
||||
const DeptIcon = department === 'technical' ? Wrench : Briefcase;
|
||||
|
||||
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
||||
@@ -44,32 +48,30 @@ export default function StaffTicketsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><DeptIcon className="w-5 h-5" /> {departmentLabel} Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{tickets.length} total tickets
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><DeptIcon className="w-5 h-5" /> {tk.ticketsTitle.replace('{dept}', departmentLabel)}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{tk.totalTickets.replace('{n}', String(tickets.length))}</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.unanswered}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${unansweredCount > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{unansweredCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Need response</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{tk.needResponse}</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Answered</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.answered}</p>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">{answeredCount}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Waiting for user</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{tk.waitingForUser}</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Total Open</p>
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">{tk.totalOpen}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{tickets.filter((t) => t.status !== 'closed').length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Excluding closed</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{tk.excludingClosed}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +87,7 @@ export default function StaffTicketsPage() {
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{status === '' ? tk.all : (tk.status as Record<string, string>)[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -98,9 +100,11 @@ export default function StaffTicketsPage() {
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<CheckCircle className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets</h3>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">{tk.noTicketsStaff}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'}
|
||||
{statusFilter
|
||||
? tk.noStatusTickets.replace('{status}', (tk.status as Record<string, string>)[statusFilter])
|
||||
: tk.noDeptTickets}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -116,10 +120,10 @@ export default function StaffTicketsPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
{(tk.status as Record<string, string>)[ticket.status] ?? ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
{(tk.priority as Record<string, string>)[ticket.priority] ?? ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
@@ -129,12 +133,12 @@ export default function StaffTicketsPage() {
|
||||
</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
<span>{tk.msg.replace('{n}', String(ticket.messages?.length || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
<span className="text-gray-400 text-sm rtl:rotate-180">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -17,8 +19,11 @@ const statusColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const t = useT();
|
||||
const tk = t.dashboard.tickets;
|
||||
const locale = useLocale();
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const router = useLocalizedRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [reply, setReply] = useState('');
|
||||
@@ -35,16 +40,16 @@ export default function TicketDetailPage() {
|
||||
onSuccess: () => {
|
||||
setReply('');
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
toast.success('Reply sent');
|
||||
toast.success(tk.replySent);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to send reply'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || tk.replyFailed),
|
||||
});
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
toast.success('Ticket closed');
|
||||
toast.success(tk.ticketClosed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,7 +73,7 @@ export default function TicketDetailPage() {
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return <div className="card p-12 text-center text-gray-500">Ticket not found</div>;
|
||||
return <div className="card p-12 text-center text-gray-500">{tk.notFound}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -77,22 +82,22 @@ export default function TicketDetailPage() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-2">
|
||||
← Back
|
||||
<span className="rtl:hidden">← </span>{tk.back}<span className="ltr:hidden"> →</span>
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ticket.subject}</h1>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
{(tk.status as Record<string, string>)[ticket.status] ?? ticket.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> Technical</> : <><Briefcase className="w-3 h-3" /> Sales</>}
|
||||
{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> {tk.department.technical}</> : <><Briefcase className="w-3 h-3" /> {tk.department.sales}</>}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(ticket.createdAt).toLocaleString()}
|
||||
{new Date(ticket.createdAt).toLocaleString(locale)}
|
||||
</span>
|
||||
{ticket.user && (
|
||||
<span className="text-xs text-gray-400">
|
||||
by {ticket.user.firstName} {ticket.user.lastName}
|
||||
{tk.by} {ticket.user.firstName} {ticket.user.lastName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,7 +108,7 @@ export default function TicketDetailPage() {
|
||||
className="btn-ghost text-sm text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={closeMutation.isPending}
|
||||
>
|
||||
Close Ticket
|
||||
{tk.closeTicket}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,17 +129,17 @@ export default function TicketDetailPage() {
|
||||
}`}>
|
||||
{!isMe && (
|
||||
<p className={`text-xs font-semibold mb-1 ${isStaff ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
{msg.sender ? `${msg.sender.firstName} ${msg.sender.lastName}` : 'Unknown'}
|
||||
{msg.sender ? `${msg.sender.firstName} ${msg.sender.lastName}` : tk.unknown}
|
||||
{isStaff && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px]">
|
||||
{msg.senderRole === 'admin' ? 'Admin' : msg.senderRole === 'technical' ? 'Technical' : 'Sales'}
|
||||
<span className="ml-1 rtl:ml-0 rtl:mr-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px]">
|
||||
{(tk.role as Record<string, string>)[msg.senderRole] ?? msg.senderRole}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.message}</p>
|
||||
<p className={`text-[10px] mt-1 ${isMe ? 'text-white/70' : 'text-gray-400'}`}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{new Date(msg.createdAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +154,7 @@ export default function TicketDetailPage() {
|
||||
<div className="flex gap-3">
|
||||
<textarea
|
||||
className="input-field flex-1 min-h-[60px] resize-none"
|
||||
placeholder="Type your reply..."
|
||||
placeholder={tk.replyPlaceholder}
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -164,13 +169,13 @@ export default function TicketDetailPage() {
|
||||
className="btn-primary self-end"
|
||||
disabled={!reply.trim() || replyMutation.isPending}
|
||||
>
|
||||
{replyMutation.isPending ? '...' : 'Send'}
|
||||
{replyMutation.isPending ? '...' : tk.send}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="card p-4 text-center text-sm text-gray-500">
|
||||
This ticket is closed. Create a new ticket if you need further assistance.
|
||||
{tk.closedNotice}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -23,6 +24,9 @@ const priorityColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TicketsPage() {
|
||||
const t = useT();
|
||||
const tk = t.dashboard.tickets;
|
||||
const locale = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
@@ -30,10 +34,10 @@ export default function TicketsPage() {
|
||||
// Staff can only create tickets to other departments
|
||||
const availableDepartments: { value: TicketDepartment; label: string }[] = [];
|
||||
if (user?.role !== 'technical') {
|
||||
availableDepartments.push({ value: 'technical', label: 'Technical Support' });
|
||||
availableDepartments.push({ value: 'technical', label: tk.departmentFull.technical });
|
||||
}
|
||||
if (user?.role !== 'sales') {
|
||||
availableDepartments.push({ value: 'sales', label: 'Sales' });
|
||||
availableDepartments.push({ value: 'sales', label: tk.departmentFull.sales });
|
||||
}
|
||||
const canCreateTicket = availableDepartments.length > 0;
|
||||
const [form, setForm] = useState<CreateTicketDto>({
|
||||
@@ -51,12 +55,12 @@ export default function TicketsPage() {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Ticket created successfully');
|
||||
toast.success(tk.createdSuccess);
|
||||
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
||||
setShowCreate(false);
|
||||
setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' });
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create ticket'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || tk.createFailed),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -68,12 +72,12 @@ export default function TicketsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Support tickets and their status</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{tk.myTickets}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{tk.myTicketsSubtitle}</p>
|
||||
</div>
|
||||
{canCreateTicket && (
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
||||
{showCreate ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ New Ticket'}
|
||||
{showCreate ? <><X className="w-4 h-4 inline" /> {t.common.cancel}</> : `+ ${tk.newTicket}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -81,14 +85,14 @@ export default function TicketsPage() {
|
||||
{/* Create Ticket Form */}
|
||||
{showCreate && canCreateTicket && (
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{tk.createNewTicket}</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.subject}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="Brief description of your issue"
|
||||
placeholder={tk.subjectPlaceholder}
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
required
|
||||
@@ -98,7 +102,7 @@ export default function TicketsPage() {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.departmentField}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.department}
|
||||
@@ -110,24 +114,24 @@ export default function TicketsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.priorityField}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="low">{tk.priority.low}</option>
|
||||
<option value="medium">{tk.priority.medium}</option>
|
||||
<option value="high">{tk.priority.high}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.messageField}</label>
|
||||
<textarea
|
||||
className="input-field min-h-[120px]"
|
||||
placeholder="Describe your issue in detail..."
|
||||
placeholder={tk.messagePlaceholder}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
required
|
||||
@@ -137,7 +141,7 @@ export default function TicketsPage() {
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Submit Ticket'}
|
||||
{createMutation.isPending ? tk.creating : tk.submitTicket}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -151,8 +155,8 @@ export default function TicketsPage() {
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<TicketIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets yet</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">{tk.noTickets}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{tk.noTicketsHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -167,21 +171,21 @@ export default function TicketsPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
{(tk.status as Record<string, string>)[ticket.status] ?? ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
{(tk.priority as Record<string, string>)[ticket.priority] ?? ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> Technical</> : <><Briefcase className="w-3 h-3" /> Sales</>}</span>
|
||||
<span className="flex items-center gap-1">{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> {tk.department.technical}</> : <><Briefcase className="w-3 h-3" /> {tk.department.sales}</>}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} message{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
<span>{tk.messages.replace('{n}', String(ticket.messages?.length || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
<span className="text-gray-400 text-sm rtl:rotate-180">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -5,16 +5,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { WalletTransaction, TransactionType } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard, FileText } from 'lucide-react';
|
||||
|
||||
const txTypeLabels: Record<TransactionType, string> = {
|
||||
charge: 'Deposit',
|
||||
deduction: 'Payment',
|
||||
refund: 'Refund',
|
||||
gateway_payment: 'Gateway payment',
|
||||
};
|
||||
|
||||
const txTypeColors: Record<TransactionType, string> = {
|
||||
charge: 'text-green-600',
|
||||
deduction: 'text-red-600',
|
||||
@@ -30,6 +24,9 @@ const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function WalletPage() {
|
||||
const t = useT();
|
||||
const w = t.dashboard.wallet;
|
||||
const locale = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
const [chargeAmount, setChargeAmount] = useState('');
|
||||
const [showCharge, setShowCharge] = useState(false);
|
||||
@@ -47,15 +44,15 @@ export default function WalletPage() {
|
||||
// Direct wallet charge (simulated — in production this would go through payment gateway)
|
||||
const chargeMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
api.post('/billing/wallet/charge', { amount, description: 'Wallet top-up' }),
|
||||
api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('Wallet charged successfully');
|
||||
toast.success(w.chargedSuccess);
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to charge wallet'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || w.chargeFailed),
|
||||
});
|
||||
|
||||
// Payment gateway charge
|
||||
@@ -63,7 +60,7 @@ export default function WalletPage() {
|
||||
mutationFn: async (amount: number) => {
|
||||
const { data } = await api.post('/billing/gateway/initiate', {
|
||||
amount,
|
||||
description: 'Wallet top-up via gateway',
|
||||
description: w.topUpGatewayDesc,
|
||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
||||
});
|
||||
return data;
|
||||
@@ -77,17 +74,17 @@ export default function WalletPage() {
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('Payment successful — wallet charged');
|
||||
toast.success(w.paymentSuccess);
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || w.paymentFailed),
|
||||
});
|
||||
|
||||
const handleCharge = (method: 'wallet' | 'gateway') => {
|
||||
const amount = Number(chargeAmount);
|
||||
if (!amount || amount < 1000) {
|
||||
toast.error('Minimum charge amount is 1,000 Toman');
|
||||
toast.error(w.minChargeError);
|
||||
return;
|
||||
}
|
||||
if (method === 'wallet') {
|
||||
@@ -98,7 +95,7 @@ export default function WalletPage() {
|
||||
};
|
||||
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('en-US', {
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString(locale, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
@@ -107,18 +104,18 @@ export default function WalletPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> Wallet</h1>
|
||||
<p className="page-subtitle">Manage your balance and transactions</p>
|
||||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> {w.title}</h1>
|
||||
<p className="page-subtitle">{w.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm opacity-80">Current Balance</p>
|
||||
<p className="text-sm opacity-80">{w.currentBalance}</p>
|
||||
<p className="text-3xl font-bold mt-1">
|
||||
{walletLoading ? '...' : formatPrice(walletData?.balance ?? 0)}
|
||||
<span className="text-lg font-normal ml-2">Toman</span>
|
||||
<span className="text-lg font-normal ml-2 rtl:ml-0 rtl:mr-2">{w.toman}</span>
|
||||
</p>
|
||||
</div>
|
||||
{!showCharge && (
|
||||
@@ -126,7 +123,7 @@ export default function WalletPage() {
|
||||
onClick={() => setShowCharge(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Top Up
|
||||
<Plus className="w-4 h-4" /> {w.topUp}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +134,7 @@ export default function WalletPage() {
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
|
||||
placeholder="Amount (Toman) — min 1,000"
|
||||
placeholder={w.amountPlaceholder}
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
min={1000}
|
||||
@@ -148,13 +145,13 @@ export default function WalletPage() {
|
||||
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{gatewayMutation.isPending ? '...' : 'Pay Now'}
|
||||
{gatewayMutation.isPending ? '...' : w.payNow}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCharge(false)}
|
||||
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -177,13 +174,13 @@ export default function WalletPage() {
|
||||
{/* Transactions */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-gray-400" /> Transaction History
|
||||
<Clock className="w-5 h-5 text-gray-400" /> {w.transactionHistory}
|
||||
</h2>
|
||||
|
||||
{txLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div className="text-center py-8 text-gray-400">{t.common.loading}</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">No transactions yet</div>
|
||||
<div className="text-center py-8 text-gray-400">{w.noTransactions}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{transactions.map((tx) => (
|
||||
@@ -194,7 +191,7 @@ export default function WalletPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{txTypeLabels[tx.type]}
|
||||
{(w.txTypes as Record<string, string>)[tx.type] ?? tx.type}
|
||||
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||||
@@ -204,16 +201,16 @@ export default function WalletPage() {
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Invoice {tx.invoice?.invoiceNumber || ''}
|
||||
{w.invoice.replace('{number}', tx.invoice?.invoiceNumber || '')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-right rtl:text-left">
|
||||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||||
{tx.type === 'deduction' ? '−' : tx.type === 'gateway_payment' ? '' : '+'}{formatPrice(tx.amount)} T
|
||||
{tx.type === 'deduction' ? '−' : tx.type === 'gateway_payment' ? '' : '+'}{formatPrice(tx.amount)} {t.common.currencyShort}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
||||
<p className="text-xs text-gray-400">{w.balanceAfter.replace('{amount}', formatPrice(tx.balanceAfter)).replace('{unit}', t.common.currencyShort)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user