Introduced new API endpoints for managing in-app notifications. The `/api/v1/notifications/me` endpoint retrieves a paginated list of notifications for the authenticated user, while the `/api/v1/notifications/me/unread-count` endpoint returns the count of unread notifications. Enhanced the OpenAPI documentation to reflect these changes, including detailed parameter descriptions and response schemas for better clarity and usability.
209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useParams } from 'next/navigation'
|
||
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import Button from '@/components/formElements/Button'
|
||
import Input from '@/components/formElements/Input'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import { addToast } from '@/lib/toast'
|
||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||
import {
|
||
adminReplyToSupportTicket,
|
||
getAdminSupportTicket,
|
||
SUPPORT_ADMIN_STATUS_LABELS,
|
||
SUPPORT_CATEGORY_LABELS,
|
||
SUPPORT_PRIORITY_LABELS,
|
||
updateSupportTicketStatus,
|
||
type SupportTicket,
|
||
} from '@/services/supportTickets'
|
||
|
||
const AdminSupportTicketDetail = () => {
|
||
const { id } = useParams<{ id: string }>()
|
||
const { showAlert } = useAlertModal()
|
||
const [ticket, setTicket] = useState<SupportTicket | null>(null)
|
||
const [reply, setReply] = useState('')
|
||
const [pending, setPending] = useState(false)
|
||
|
||
useEffect(() => {
|
||
void getAdminSupportTicket(id)
|
||
.then(setTicket)
|
||
.catch(() => addToast({ title: 'دریافت تیکت ناموفق بود', color: 'danger' }))
|
||
}, [id])
|
||
|
||
const send = async () => {
|
||
if (!reply.trim()) return
|
||
try {
|
||
setPending(true)
|
||
const updated = await adminReplyToSupportTicket(id, reply)
|
||
|
||
setTicket(updated)
|
||
setReply('')
|
||
addToast({ title: 'پاسخ ثبت شد و پیامک کاربر در صف ارسال قرار گرفت', color: 'success' })
|
||
} catch {
|
||
addToast({ title: 'ثبت پاسخ ناموفق بود', color: 'danger' })
|
||
} finally {
|
||
setPending(false)
|
||
}
|
||
}
|
||
|
||
const changeStatus = async (status: string) => {
|
||
try {
|
||
setTicket(await updateSupportTicketStatus(id, status))
|
||
addToast({ title: 'وضعیت تیکت تغییر کرد', color: 'success' })
|
||
} catch {
|
||
addToast({ title: 'تغییر وضعیت ناموفق بود', color: 'danger' })
|
||
}
|
||
}
|
||
|
||
const confirmSend = () => {
|
||
if (!reply.trim()) return
|
||
|
||
showAlert('این پاسخ ثبت و پیامک اطلاعرسانی برای کاربر ارسال شود؟', () => {
|
||
void send()
|
||
})
|
||
}
|
||
|
||
const confirmStatusChange = (nextStatus: string) => {
|
||
if (!ticket || nextStatus === ticket.status) return
|
||
|
||
if (nextStatus === 'closed') {
|
||
showAlert(
|
||
'این تیکت بسته شود؟ کاربر دیگر نمیتواند پیام بفرستد و در صورت بستن توسط ادمین، خودش نمیتواند دوباره باز کند.',
|
||
() => {
|
||
void changeStatus('closed')
|
||
},
|
||
undefined,
|
||
{ dangerAccept: true }
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
const label = SUPPORT_ADMIN_STATUS_LABELS[nextStatus] ?? nextStatus
|
||
|
||
showAlert(`وضعیت تیکت به «${label}» تغییر کند؟`, () => {
|
||
void changeStatus(nextStatus)
|
||
})
|
||
}
|
||
|
||
const confirmClose = () => {
|
||
confirmStatusChange('closed')
|
||
}
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar pageTitle={ticket?.subject ?? 'جزئیات تیکت'} />
|
||
<div className="admin-page-container mx-auto max-w-4xl space-y-5">
|
||
<Button
|
||
size="sm"
|
||
to={APP_ROUTES.SUPPORT_TICKETS}
|
||
variant="light"
|
||
>
|
||
بازگشت به تیکتها
|
||
</Button>
|
||
{!ticket ? (
|
||
<p className="py-12 text-center text-text-muted">در حال دریافت…</p>
|
||
) : (
|
||
<>
|
||
<div className="rounded-2xl border border-default-200 bg-white p-5">
|
||
<div className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||
<div>
|
||
<p className="text-text-muted">کاربر</p>
|
||
<strong>{ticket.user.displayName || 'کاربر'}</strong>
|
||
<p dir="ltr">{formatIranianMobile(ticket.user.mobile)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-text-muted">دستهبندی</p>
|
||
<strong>{SUPPORT_CATEGORY_LABELS[ticket.category]}</strong>
|
||
</div>
|
||
<div>
|
||
<p className="text-text-muted">اولویت</p>
|
||
<strong>{SUPPORT_PRIORITY_LABELS[ticket.priority]}</strong>
|
||
</div>
|
||
<label>
|
||
<span className="text-text-muted">وضعیت</span>
|
||
<select
|
||
className="mt-1 block w-full rounded-lg border border-default-300 p-2"
|
||
value={ticket.status}
|
||
onChange={(event) => {
|
||
confirmStatusChange(event.target.value)
|
||
}}
|
||
>
|
||
{Object.entries(SUPPORT_ADMIN_STATUS_LABELS).map(([value, label]) => (
|
||
<option
|
||
key={value}
|
||
value={value}
|
||
>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{ticket.status !== 'closed' ? (
|
||
<div className="mt-4 flex justify-end">
|
||
<Button
|
||
color="danger"
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={confirmClose}
|
||
>
|
||
بستن تیکت
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="mt-4 text-sm text-text-muted">
|
||
{ticket.closedBy === 'user'
|
||
? 'این تیکت توسط کاربر بسته شده است.'
|
||
: 'این تیکت توسط پشتیبانی بسته شده است؛ کاربر نمیتواند دوباره باز کند.'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="space-y-3 rounded-2xl border border-default-200 bg-white p-5">
|
||
{ticket.messages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={`max-w-[82%] rounded-2xl p-4 ${message.isAdmin ? 'mr-auto bg-primary text-white' : 'ml-auto bg-default-100'}`}
|
||
>
|
||
<p className="text-xs opacity-70">{message.isAdmin ? 'پشتیبانی' : ticket.user.displayName || 'کاربر'}</p>
|
||
<p className="mt-1 whitespace-pre-wrap leading-7">{message.body}</p>
|
||
<p className="mt-2 text-xs opacity-60">{formatPersianDate(message.createdAt)}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{ticket.status !== 'closed' ? (
|
||
<div className="rounded-2xl border border-default-200 bg-white p-5">
|
||
<Input
|
||
generalType="textarea"
|
||
label="پاسخ پشتیبانی"
|
||
name="adminReply"
|
||
textAreaMinRows={5}
|
||
value={reply}
|
||
onValueChange={(value) => {
|
||
setReply(String(value))
|
||
}}
|
||
/>
|
||
<p className="my-3 text-xs text-text-muted">
|
||
میتوانید چند پیام پشتسرهم بفرستید. کاربر فقط بعد از پاسخ شما یک پیام میتواند بفرستد. پس از ثبت پاسخ، برای کاربر پیامک
|
||
اطلاعرسانی ارسال میشود.
|
||
</p>
|
||
<Button
|
||
isLoading={pending}
|
||
onClick={confirmSend}
|
||
>
|
||
ثبت پاسخ و ارسال پیامک
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default AdminSupportTicketDetail
|