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 { 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>
))}
+86
View File
@@ -249,6 +249,92 @@ const en: Dictionary = {
deleted: 'Service deleted',
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: 'سرویس حذف شد',
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