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:
keyhan
2026-06-11 12:54:19 +03:30
parent 2bc86c03b8
commit e99dccaff1
8 changed files with 309 additions and 123 deletions
@@ -2,7 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 api from '@/lib/api';
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types'; import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types';
import { ClipboardList, Wrench, Briefcase, User, Inbox } from 'lucide-react'; import { ClipboardList, Wrench, Briefcase, User, Inbox } from 'lucide-react';
@@ -21,6 +22,9 @@ const priorityColors: Record<string, string> = {
}; };
export default function AdminTicketsPage() { export default function AdminTicketsPage() {
const t = useT();
const tk = t.dashboard.tickets;
const locale = useLocale();
const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>(''); const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>('');
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>(''); const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
@@ -41,46 +45,46 @@ export default function AdminTicketsPage() {
}); });
const formatResponseTime = (minutes: number) => { const formatResponseTime = (minutes: number) => {
if (minutes < 60) return `${minutes} min`; if (minutes < 60) return tk.minutes.replace('{n}', String(minutes));
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`; if (minutes < 1440) return tk.hours.replace('{n}', String(Math.round(minutes / 60)));
return `${Math.round(minutes / 1440)} days`; return tk.days.replace('{n}', String(Math.round(minutes / 1440)));
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><ClipboardList className="w-6 h-6" /> All Tickets</h1> <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">Overview of all support tickets across departments</p> <p className="text-sm text-gray-500 mt-1">{tk.allTicketsSubtitle}</p>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
{stats && ( {stats && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-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> <p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTickets}</p>
</div> </div>
<div className="card p-4 border-l-4 border-l-red-500"> <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'}`}> <p className={`text-2xl font-bold mt-1 ${stats.openTickets > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.openTickets} {stats.openTickets}
</p> </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>
<div className="card p-4"> <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"> <p className="text-2xl font-bold text-primary-600 mt-1">
{stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'} {stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'}
</p> </p>
</div> </div>
<div className="card p-4"> <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"> <div className="mt-1 space-y-0.5">
{Object.entries(stats.byDepartment).map(([dept, data]) => ( {Object.entries(stats.byDepartment).map(([dept, data]) => (
<div key={dept} className="flex items-center justify-between text-xs"> <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="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 className="text-gray-400"> / {data.total}</span>
</span> </span>
</div> </div>
@@ -93,7 +97,7 @@ export default function AdminTicketsPage() {
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div className="flex flex-wrap gap-2"> <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) => ( {(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => (
<button <button
key={dept} key={dept}
@@ -104,12 +108,12 @@ export default function AdminTicketsPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : '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> </button>
))} ))}
</div> </div>
<div className="flex flex-wrap gap-2"> <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) => ( {(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
<button <button
key={status} key={status}
@@ -120,7 +124,7 @@ export default function AdminTicketsPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : '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> </button>
))} ))}
</div> </div>
@@ -134,7 +138,7 @@ export default function AdminTicketsPage() {
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<Inbox className="w-10 h-10 mx-auto text-gray-300" /> <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>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -149,13 +153,13 @@ export default function AdminTicketsPage() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3> <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]}`}> <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>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}> <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>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 flex items-center gap-1"> <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> </span>
</div> </div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500"> <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> <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></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>
</div> </div>
<span className="text-gray-400 text-sm"></span> <span className="text-gray-400 text-sm rtl:rotate-180"></span>
</div> </div>
</Link> </Link>
))} ))}
@@ -2,7 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import type { Ticket, TicketStatus } from '@/types'; import type { Ticket, TicketStatus } from '@/types';
@@ -22,12 +23,15 @@ const priorityColors: Record<string, string> = {
}; };
export default function StaffTicketsPage() { export default function StaffTicketsPage() {
const t = useT();
const tk = t.dashboard.tickets;
const locale = useLocale();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>(''); const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
// Determine which department this staff member handles // Determine which department this staff member handles
const department = user?.role === 'sales' ? 'sales' : 'technical'; 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 DeptIcon = department === 'technical' ? Wrench : Briefcase;
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({ const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
@@ -44,32 +48,30 @@ export default function StaffTicketsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><DeptIcon className="w-5 h-5" /> {departmentLabel} Tickets</h1> <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"> <p className="text-sm text-gray-500 mt-1">{tk.totalTickets.replace('{n}', String(tickets.length))}</p>
{tickets.length} total tickets
</p>
</div> </div>
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="card p-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'}`}> <p className={`text-2xl font-bold mt-1 ${unansweredCount > 0 ? 'text-red-600' : 'text-green-600'}`}>
{unansweredCount} {unansweredCount}
</p> </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>
<div className="card p-4"> <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-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>
<div className="card p-4"> <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"> <p className="text-2xl font-bold text-gray-900 mt-1">
{tickets.filter((t) => t.status !== 'closed').length} {tickets.filter((t) => t.status !== 'closed').length}
</p> </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>
</div> </div>
@@ -85,7 +87,7 @@ export default function StaffTicketsPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' : '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> </button>
))} ))}
</div> </div>
@@ -98,9 +100,11 @@ export default function StaffTicketsPage() {
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<CheckCircle className="w-10 h-10 mx-auto text-gray-300" /> <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"> <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> </p>
</div> </div>
) : ( ) : (
@@ -116,10 +120,10 @@ export default function StaffTicketsPage() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3> <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]}`}> <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>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}> <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>
</div> </div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500"> <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> <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></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>
</div> </div>
<span className="text-gray-400 text-sm"></span> <span className="text-gray-400 text-sm rtl:rotate-180"></span>
</div> </div>
</Link> </Link>
))} ))}
@@ -2,7 +2,9 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -17,8 +19,11 @@ const statusColors: Record<string, string> = {
}; };
export default function TicketDetailPage() { export default function TicketDetailPage() {
const t = useT();
const tk = t.dashboard.tickets;
const locale = useLocale();
const { id } = useParams(); const { id } = useParams();
const router = useRouter(); const router = useLocalizedRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [reply, setReply] = useState(''); const [reply, setReply] = useState('');
@@ -35,16 +40,16 @@ export default function TicketDetailPage() {
onSuccess: () => { onSuccess: () => {
setReply(''); setReply('');
queryClient.invalidateQueries({ queryKey: ['ticket', id] }); 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({ const closeMutation = useMutation({
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data), mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', id] }); queryClient.invalidateQueries({ queryKey: ['ticket', id] });
toast.success('Ticket closed'); toast.success(tk.ticketClosed);
}, },
}); });
@@ -68,7 +73,7 @@ export default function TicketDetailPage() {
} }
if (!ticket) { 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 ( return (
@@ -77,22 +82,22 @@ export default function TicketDetailPage() {
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-2"> <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> </button>
<h1 className="text-xl font-bold text-gray-900">{ticket.subject}</h1> <h1 className="text-xl font-bold text-gray-900">{ticket.subject}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap"> <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]}`}> <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>
<span className="text-xs text-gray-500 flex items-center gap-1"> <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>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{new Date(ticket.createdAt).toLocaleString()} {new Date(ticket.createdAt).toLocaleString(locale)}
</span> </span>
{ticket.user && ( {ticket.user && (
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
by {ticket.user.firstName} {ticket.user.lastName} {tk.by} {ticket.user.firstName} {ticket.user.lastName}
</span> </span>
)} )}
</div> </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" className="btn-ghost text-sm text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={closeMutation.isPending} disabled={closeMutation.isPending}
> >
Close Ticket {tk.closeTicket}
</button> </button>
)} )}
</div> </div>
@@ -124,17 +129,17 @@ export default function TicketDetailPage() {
}`}> }`}>
{!isMe && ( {!isMe && (
<p className={`text-xs font-semibold mb-1 ${isStaff ? 'text-blue-600' : 'text-gray-500'}`}> <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 && ( {isStaff && (
<span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px]"> <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]">
{msg.senderRole === 'admin' ? 'Admin' : msg.senderRole === 'technical' ? 'Technical' : 'Sales'} {(tk.role as Record<string, string>)[msg.senderRole] ?? msg.senderRole}
</span> </span>
)} )}
</p> </p>
)} )}
<p className="text-sm whitespace-pre-wrap">{msg.message}</p> <p className="text-sm whitespace-pre-wrap">{msg.message}</p>
<p className={`text-[10px] mt-1 ${isMe ? 'text-white/70' : 'text-gray-400'}`}> <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> </p>
</div> </div>
</div> </div>
@@ -149,7 +154,7 @@ export default function TicketDetailPage() {
<div className="flex gap-3"> <div className="flex gap-3">
<textarea <textarea
className="input-field flex-1 min-h-[60px] resize-none" className="input-field flex-1 min-h-[60px] resize-none"
placeholder="Type your reply..." placeholder={tk.replyPlaceholder}
value={reply} value={reply}
onChange={(e) => setReply(e.target.value)} onChange={(e) => setReply(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -164,13 +169,13 @@ export default function TicketDetailPage() {
className="btn-primary self-end" className="btn-primary self-end"
disabled={!reply.trim() || replyMutation.isPending} disabled={!reply.trim() || replyMutation.isPending}
> >
{replyMutation.isPending ? '...' : 'Send'} {replyMutation.isPending ? '...' : tk.send}
</button> </button>
</div> </div>
</form> </form>
) : ( ) : (
<div className="card p-4 text-center text-sm text-gray-500"> <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>
)} )}
</div> </div>
@@ -2,7 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -23,6 +24,9 @@ const priorityColors: Record<string, string> = {
}; };
export default function TicketsPage() { export default function TicketsPage() {
const t = useT();
const tk = t.dashboard.tickets;
const locale = useLocale();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@@ -30,10 +34,10 @@ export default function TicketsPage() {
// Staff can only create tickets to other departments // Staff can only create tickets to other departments
const availableDepartments: { value: TicketDepartment; label: string }[] = []; const availableDepartments: { value: TicketDepartment; label: string }[] = [];
if (user?.role !== 'technical') { if (user?.role !== 'technical') {
availableDepartments.push({ value: 'technical', label: 'Technical Support' }); availableDepartments.push({ value: 'technical', label: tk.departmentFull.technical });
} }
if (user?.role !== 'sales') { if (user?.role !== 'sales') {
availableDepartments.push({ value: 'sales', label: 'Sales' }); availableDepartments.push({ value: 'sales', label: tk.departmentFull.sales });
} }
const canCreateTicket = availableDepartments.length > 0; const canCreateTicket = availableDepartments.length > 0;
const [form, setForm] = useState<CreateTicketDto>({ const [form, setForm] = useState<CreateTicketDto>({
@@ -51,12 +55,12 @@ export default function TicketsPage() {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data), mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
onSuccess: () => { onSuccess: () => {
toast.success('Ticket created successfully'); toast.success(tk.createdSuccess);
queryClient.invalidateQueries({ queryKey: ['my-tickets'] }); queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
setShowCreate(false); setShowCreate(false);
setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' }); 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) => { const handleSubmit = (e: React.FormEvent) => {
@@ -68,12 +72,12 @@ export default function TicketsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">My Tickets</h1> <h1 className="text-2xl font-bold text-gray-900">{tk.myTickets}</h1>
<p className="text-sm text-gray-500 mt-1">Support tickets and their status</p> <p className="text-sm text-gray-500 mt-1">{tk.myTicketsSubtitle}</p>
</div> </div>
{canCreateTicket && ( {canCreateTicket && (
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary"> <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> </button>
)} )}
</div> </div>
@@ -81,14 +85,14 @@ export default function TicketsPage() {
{/* Create Ticket Form */} {/* Create Ticket Form */}
{showCreate && canCreateTicket && ( {showCreate && canCreateTicket && (
<form onSubmit={handleSubmit} className="card p-6 space-y-4"> <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> <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 <input
type="text" type="text"
className="input-field" className="input-field"
placeholder="Brief description of your issue" placeholder={tk.subjectPlaceholder}
value={form.subject} value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })} onChange={(e) => setForm({ ...form, subject: e.target.value })}
required required
@@ -98,7 +102,7 @@ export default function TicketsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <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 <select
className="input-field" className="input-field"
value={form.department} value={form.department}
@@ -110,24 +114,24 @@ export default function TicketsPage() {
</select> </select>
</div> </div>
<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 <select
className="input-field" className="input-field"
value={form.priority} value={form.priority}
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })} onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
> >
<option value="low">Low</option> <option value="low">{tk.priority.low}</option>
<option value="medium">Medium</option> <option value="medium">{tk.priority.medium}</option>
<option value="high">High</option> <option value="high">{tk.priority.high}</option>
</select> </select>
</div> </div>
</div> </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 <textarea
className="input-field min-h-[120px]" className="input-field min-h-[120px]"
placeholder="Describe your issue in detail..." placeholder={tk.messagePlaceholder}
value={form.message} value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })} onChange={(e) => setForm({ ...form, message: e.target.value })}
required required
@@ -137,7 +141,7 @@ export default function TicketsPage() {
<div className="flex justify-end"> <div className="flex justify-end">
<button type="submit" className="btn-primary" disabled={createMutation.isPending}> <button type="submit" className="btn-primary" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Submit Ticket'} {createMutation.isPending ? tk.creating : tk.submitTicket}
</button> </button>
</div> </div>
</form> </form>
@@ -151,8 +155,8 @@ export default function TicketsPage() {
) : tickets.length === 0 ? ( ) : tickets.length === 0 ? (
<div className="card p-12 text-center"> <div className="card p-12 text-center">
<TicketIcon className="w-10 h-10 mx-auto text-gray-300" /> <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> <h3 className="mt-3 text-lg font-semibold text-gray-700">{tk.noTickets}</h3>
<p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p> <p className="text-sm text-gray-500 mt-1">{tk.noTicketsHint}</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -167,21 +171,21 @@ export default function TicketsPage() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3> <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]}`}> <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>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}> <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>
</div> </div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500"> <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></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></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>
</div> </div>
<span className="text-gray-400 text-sm"></span> <span className="text-gray-400 text-sm rtl:rotate-180"></span>
</div> </div>
</Link> </Link>
))} ))}
@@ -5,16 +5,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import type { WalletTransaction, TransactionType } from '@/types'; 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'; 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> = { const txTypeColors: Record<TransactionType, string> = {
charge: 'text-green-600', charge: 'text-green-600',
deduction: 'text-red-600', deduction: 'text-red-600',
@@ -30,6 +24,9 @@ const txTypeIcons: Record<TransactionType, React.ReactNode> = {
}; };
export default function WalletPage() { export default function WalletPage() {
const t = useT();
const w = t.dashboard.wallet;
const locale = useLocale();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [chargeAmount, setChargeAmount] = useState(''); const [chargeAmount, setChargeAmount] = useState('');
const [showCharge, setShowCharge] = useState(false); 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) // Direct wallet charge (simulated — in production this would go through payment gateway)
const chargeMutation = useMutation({ const chargeMutation = useMutation({
mutationFn: (amount: number) => mutationFn: (amount: number) =>
api.post('/billing/wallet/charge', { amount, description: 'Wallet top-up' }), api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }); queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] }); queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
toast.success('Wallet charged successfully'); toast.success(w.chargedSuccess);
setChargeAmount(''); setChargeAmount('');
setShowCharge(false); 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 // Payment gateway charge
@@ -63,7 +60,7 @@ export default function WalletPage() {
mutationFn: async (amount: number) => { mutationFn: async (amount: number) => {
const { data } = await api.post('/billing/gateway/initiate', { const { data } = await api.post('/billing/gateway/initiate', {
amount, amount,
description: 'Wallet top-up via gateway', description: w.topUpGatewayDesc,
callbackUrl: `${window.location.origin}/dashboard/wallet`, callbackUrl: `${window.location.origin}/dashboard/wallet`,
}); });
return data; return data;
@@ -77,17 +74,17 @@ export default function WalletPage() {
}); });
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }); queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] }); queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
toast.success('Payment successful — wallet charged'); toast.success(w.paymentSuccess);
setChargeAmount(''); setChargeAmount('');
setShowCharge(false); 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 handleCharge = (method: 'wallet' | 'gateway') => {
const amount = Number(chargeAmount); const amount = Number(chargeAmount);
if (!amount || amount < 1000) { if (!amount || amount < 1000) {
toast.error('Minimum charge amount is 1,000 Toman'); toast.error(w.minChargeError);
return; return;
} }
if (method === 'wallet') { if (method === 'wallet') {
@@ -98,7 +95,7 @@ export default function WalletPage() {
}; };
const formatPrice = (n: number) => Number(n).toLocaleString('en-US'); 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', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
}); });
@@ -107,18 +104,18 @@ export default function WalletPage() {
return ( return (
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in"> <div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
<div> <div>
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> Wallet</h1> <h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> {w.title}</h1>
<p className="page-subtitle">Manage your balance and transactions</p> <p className="page-subtitle">{w.subtitle}</p>
</div> </div>
{/* Balance Card */} {/* Balance Card */}
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white"> <div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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"> <p className="text-3xl font-bold mt-1">
{walletLoading ? '...' : formatPrice(walletData?.balance ?? 0)} {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> </p>
</div> </div>
{!showCharge && ( {!showCharge && (
@@ -126,7 +123,7 @@ export default function WalletPage() {
onClick={() => setShowCharge(true)} 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" 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> </button>
)} )}
</div> </div>
@@ -137,7 +134,7 @@ export default function WalletPage() {
<input <input
type="number" 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" 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} value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)} onChange={(e) => setChargeAmount(e.target.value)}
min={1000} 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" 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" /> <CreditCard className="w-4 h-4" />
{gatewayMutation.isPending ? '...' : 'Pay Now'} {gatewayMutation.isPending ? '...' : w.payNow}
</button> </button>
<button <button
onClick={() => setShowCharge(false)} onClick={() => setShowCharge(false)}
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors" className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
> >
Cancel {t.common.cancel}
</button> </button>
</div> </div>
@@ -177,13 +174,13 @@ export default function WalletPage() {
{/* Transactions */} {/* Transactions */}
<div className="card"> <div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> <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> </h2>
{txLoading ? ( {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 ? ( ) : 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"> <div className="divide-y divide-gray-100">
{transactions.map((tx) => ( {transactions.map((tx) => (
@@ -194,7 +191,7 @@ export default function WalletPage() {
</div> </div>
<div> <div>
<p className="text-sm font-medium text-gray-900"> <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>} {tx.description && <span className="text-gray-500 font-normal"> {tx.description}</span>}
</p> </p>
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</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" 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" /> <FileText className="w-3 h-3" />
Invoice {tx.invoice?.invoiceNumber || ''} {w.invoice.replace('{number}', tx.invoice?.invoiceNumber || '')}
</Link> </Link>
)} )}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right rtl:text-left">
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}> <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>
<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>
</div> </div>
))} ))}
+86
View File
@@ -249,6 +249,92 @@ const en: Dictionary = {
deleted: 'Service deleted', deleted: 'Service deleted',
deletedWithCredit: 'Service deleted. Prepaid resources are on your dashboard.', deletedWithCredit: 'Service deleted. Prepaid resources are on your dashboard.',
}, },
wallet: {
title: 'Wallet',
subtitle: 'Manage your balance and transactions',
currentBalance: 'Current Balance',
toman: 'Toman',
topUp: 'Top Up',
amountPlaceholder: 'Amount (Toman) — min 1,000',
payNow: 'Pay Now',
transactionHistory: 'Transaction History',
noTransactions: 'No transactions yet',
balanceAfter: 'Balance: {amount} {unit}',
invoice: 'Invoice {number}',
minChargeError: 'Minimum charge amount is 1,000 Toman',
chargedSuccess: 'Wallet charged successfully',
chargeFailed: 'Failed to charge wallet',
paymentSuccess: 'Payment successful — wallet charged',
paymentFailed: 'Payment failed',
topUpDesc: 'Wallet top-up',
topUpGatewayDesc: 'Wallet top-up via gateway',
txTypes: {
charge: 'Deposit',
deduction: 'Payment',
refund: 'Refund',
gateway_payment: 'Gateway payment',
},
},
tickets: {
status: { open: 'Open', waiting: 'Waiting', answered: 'Answered', closed: 'Closed' },
priority: { low: 'Low', medium: 'Medium', high: 'High' },
department: { technical: 'Technical', sales: 'Sales' },
departmentFull: { technical: 'Technical Support', sales: 'Sales' },
role: { admin: 'Admin', technical: 'Technical', sales: 'Sales' },
myTickets: 'My Tickets',
myTicketsSubtitle: 'Support tickets and their status',
newTicket: 'New Ticket',
createNewTicket: 'Create New Ticket',
subject: 'Subject',
subjectPlaceholder: 'Brief description of your issue',
departmentField: 'Department',
priorityField: 'Priority',
messageField: 'Message',
messagePlaceholder: 'Describe your issue in detail...',
submitTicket: 'Submit Ticket',
creating: 'Creating...',
noTickets: 'No tickets yet',
noTicketsHint: 'Create a ticket if you need help',
createdSuccess: 'Ticket created successfully',
createFailed: 'Failed to create ticket',
messages: '{n} message(s)',
msg: '{n} msg',
back: 'Back',
notFound: 'Ticket not found',
by: 'by',
closeTicket: 'Close Ticket',
closedNotice: 'This ticket is closed. Create a new ticket if you need further assistance.',
replyPlaceholder: 'Type your reply...',
send: 'Send',
replySent: 'Reply sent',
replyFailed: 'Failed to send reply',
ticketClosed: 'Ticket closed',
unknown: 'Unknown',
ticketsTitle: '{dept} Tickets',
totalTickets: '{n} total tickets',
unanswered: 'Unanswered',
needResponse: 'Need response',
answered: 'Answered',
waitingForUser: 'Waiting for user',
totalOpen: 'Total Open',
excludingClosed: 'Excluding closed',
all: 'All',
noTicketsStaff: 'No tickets',
noStatusTickets: 'No {status} tickets',
noDeptTickets: 'No tickets in this department',
allTickets: 'All Tickets',
allTicketsSubtitle: 'Overview of all support tickets across departments',
totalTicketsStat: 'Total Tickets',
needStaffResponse: 'Need staff response',
avgResponseTime: 'Avg Response Time',
byDepartment: 'By Department',
departmentLabel: 'Department:',
statusLabel: 'Status:',
noTicketsFound: 'No tickets found',
minutes: '{n} min',
hours: '{n} hours',
days: '{n} days',
},
}, },
}; };
+86
View File
@@ -248,6 +248,92 @@ const fa = {
deleted: 'سرویس حذف شد', deleted: 'سرویس حذف شد',
deletedWithCredit: 'سرویس حذف شد. منابع پیش‌پرداختت روی داشبورد است.', deletedWithCredit: 'سرویس حذف شد. منابع پیش‌پرداختت روی داشبورد است.',
}, },
wallet: {
title: 'کیف‌پول',
subtitle: 'موجودی و تراکنش‌هایت را مدیریت کن',
currentBalance: 'موجودی فعلی',
toman: 'تومان',
topUp: 'افزایش موجودی',
amountPlaceholder: 'مبلغ (تومان) — حداقل ۱٬۰۰۰',
payNow: 'پرداخت',
transactionHistory: 'تاریخچهٔ تراکنش‌ها',
noTransactions: 'هنوز تراکنشی نداری',
balanceAfter: 'موجودی: {amount} {unit}',
invoice: 'فاکتور {number}',
minChargeError: 'حداقل مبلغ شارژ ۱٬۰۰۰ تومان است',
chargedSuccess: 'کیف‌پول با موفقیت شارژ شد',
chargeFailed: 'شارژ کیف‌پول ناموفق بود',
paymentSuccess: 'پرداخت موفق — کیف‌پول شارژ شد',
paymentFailed: 'پرداخت ناموفق بود',
topUpDesc: 'شارژ کیف‌پول',
topUpGatewayDesc: 'شارژ کیف‌پول از طریق درگاه',
txTypes: {
charge: 'واریز',
deduction: 'پرداخت',
refund: 'بازگشت وجه',
gateway_payment: 'پرداخت درگاهی',
},
},
tickets: {
status: { open: 'باز', waiting: 'در انتظار', answered: 'پاسخ‌داده‌شده', closed: 'بسته' },
priority: { low: 'کم', medium: 'متوسط', high: 'زیاد' },
department: { technical: 'فنی', sales: 'فروش' },
departmentFull: { technical: 'پشتیبانی فنی', sales: 'فروش' },
role: { admin: 'مدیر', technical: 'فنی', sales: 'فروش' },
myTickets: 'تیکت‌های من',
myTicketsSubtitle: 'تیکت‌های پشتیبانی و وضعیتشان',
newTicket: 'تیکت جدید',
createNewTicket: 'ساخت تیکت جدید',
subject: 'موضوع',
subjectPlaceholder: 'توضیح کوتاهی از مشکلت',
departmentField: 'بخش',
priorityField: 'اولویت',
messageField: 'پیام',
messagePlaceholder: 'مشکلت را با جزئیات توضیح بده…',
submitTicket: 'ثبت تیکت',
creating: 'در حال ساخت…',
noTickets: 'هنوز تیکتی نداری',
noTicketsHint: 'اگر به کمک نیاز داری یک تیکت بساز',
createdSuccess: 'تیکت با موفقیت ساخته شد',
createFailed: 'ساخت تیکت ناموفق بود',
messages: '{n} پیام',
msg: '{n} پیام',
back: 'بازگشت',
notFound: 'تیکت پیدا نشد',
by: 'توسط',
closeTicket: 'بستن تیکت',
closedNotice: 'این تیکت بسته شده است. اگر باز هم کمک خواستی، تیکت جدیدی بساز.',
replyPlaceholder: 'پاسخت را بنویس…',
send: 'ارسال',
replySent: 'پاسخ ارسال شد',
replyFailed: 'ارسال پاسخ ناموفق بود',
ticketClosed: 'تیکت بسته شد',
unknown: 'ناشناس',
ticketsTitle: 'تیکت‌های {dept}',
totalTickets: '{n} تیکت در مجموع',
unanswered: 'بی‌پاسخ',
needResponse: 'نیازمند پاسخ',
answered: 'پاسخ‌داده‌شده',
waitingForUser: 'منتظر کاربر',
totalOpen: 'مجموع باز',
excludingClosed: 'به‌جز بسته‌ها',
all: 'همه',
noTicketsStaff: 'تیکتی نیست',
noStatusTickets: 'تیکت «{status}» وجود ندارد',
noDeptTickets: 'در این بخش تیکتی نیست',
allTickets: 'همهٔ تیکت‌ها',
allTicketsSubtitle: 'نمای کلیِ همهٔ تیکت‌های پشتیبانی در همهٔ بخش‌ها',
totalTicketsStat: 'کل تیکت‌ها',
needStaffResponse: 'نیازمند پاسخ پشتیبان',
avgResponseTime: 'میانگین زمان پاسخ',
byDepartment: 'بر اساس بخش',
departmentLabel: 'بخش:',
statusLabel: 'وضعیت:',
noTicketsFound: 'تیکتی پیدا نشد',
minutes: '{n} دقیقه',
hours: '{n} ساعت',
days: '{n} روز',
},
}, },
}; };
File diff suppressed because one or more lines are too long