34993d417f
Introduce path-prefixed locale routing under app/[lang] with a middleware that detects locale from cookie/Accept-Language (default fa-IR) and redirects. Add fa-IR (source of truth) and en-US dictionaries, a server getDictionary, a client I18nProvider/useT, locale-aware Link + router helpers, and a language switcher. The root [lang] layout sets html lang/dir and the per-locale font (Peyda for fa, Inter for en). Landing sections and the login/register/auth shell now read all copy from the dictionaries; dashboard localization follows in a later commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
7.7 KiB
TypeScript
193 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import Link from 'next/link';
|
|
import api from '@/lib/api';
|
|
import { useAuthStore } from '@/lib/store';
|
|
import { toast } from 'react-toastify';
|
|
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
|
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
|
|
|
|
const statusColors: Record<string, string> = {
|
|
open: 'bg-yellow-100 text-yellow-700',
|
|
waiting: 'bg-orange-100 text-orange-700',
|
|
answered: 'bg-green-100 text-green-700',
|
|
closed: 'bg-gray-100 text-gray-500',
|
|
};
|
|
|
|
const priorityColors: Record<string, string> = {
|
|
low: 'bg-blue-100 text-blue-700',
|
|
medium: 'bg-yellow-100 text-yellow-700',
|
|
high: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
export default function TicketsPage() {
|
|
const queryClient = useQueryClient();
|
|
const user = useAuthStore((s) => s.user);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
// 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' });
|
|
}
|
|
if (user?.role !== 'sales') {
|
|
availableDepartments.push({ value: 'sales', label: 'Sales' });
|
|
}
|
|
const canCreateTicket = availableDepartments.length > 0;
|
|
const [form, setForm] = useState<CreateTicketDto>({
|
|
subject: '',
|
|
department: availableDepartments[0]?.value || 'technical',
|
|
priority: 'medium',
|
|
message: '',
|
|
});
|
|
|
|
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
|
queryKey: ['my-tickets'],
|
|
queryFn: () => api.get('/tickets/my').then((r) => r.data),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
|
onSuccess: () => {
|
|
toast.success('Ticket created successfully');
|
|
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'),
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
createMutation.mutate(form);
|
|
};
|
|
|
|
return (
|
|
<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>
|
|
</div>
|
|
{canCreateTicket && (
|
|
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
|
{showCreate ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ New Ticket'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
|
<input
|
|
type="text"
|
|
className="input-field"
|
|
placeholder="Brief description of your issue"
|
|
value={form.subject}
|
|
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
|
required
|
|
minLength={3}
|
|
/>
|
|
</div>
|
|
|
|
<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>
|
|
<select
|
|
className="input-field"
|
|
value={form.department}
|
|
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
|
|
>
|
|
{availableDepartments.map((dept) => (
|
|
<option key={dept.value} value={dept.value}>{dept.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</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>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Message</label>
|
|
<textarea
|
|
className="input-field min-h-[120px]"
|
|
placeholder="Describe your issue in detail..."
|
|
value={form.message}
|
|
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
|
required
|
|
minLength={10}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? 'Creating...' : 'Submit Ticket'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Tickets List */}
|
|
{isLoading ? (
|
|
<div className="text-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
|
</div>
|
|
) : 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>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{tickets.map((ticket) => (
|
|
<Link
|
|
key={ticket.id}
|
|
href={`/dashboard/tickets/${ticket.id}`}
|
|
className="card p-4 block hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<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}
|
|
</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[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>•</span>
|
|
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { 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>
|
|
</div>
|
|
</div>
|
|
<span className="text-gray-400 text-sm">→</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|