275 lines
9.4 KiB
TypeScript
275 lines
9.4 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Mail, RotateCcw, Search } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { EmptyTableRow } from '../components/EmptyState'
|
|
import { PageTitle } from '../components/PageTitle'
|
|
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
|
|
import { Pagination } from '../components/Pagination'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
|
import { listContactSubmissions } from '../services/contactSubmissionService'
|
|
import type {
|
|
ContactSubmission,
|
|
ContactSubmissionsListResponse,
|
|
} from '../types/contactSubmission'
|
|
import filterStyles from '../components/ListFiltersPanel.module.css'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import styles from './WebsiteContactPage.module.css'
|
|
|
|
const PAGE_SIZE = 20
|
|
const COLUMN_COUNT = 6
|
|
|
|
function formatDateTime(value: string, locale: 'en' | 'fa') {
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
|
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
|
return {
|
|
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
|
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
|
}
|
|
}
|
|
|
|
export function WebsiteContactPage() {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const [data, setData] = useState<ContactSubmissionsListResponse | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [appliedQuery, setAppliedQuery] = useState('')
|
|
const [draftQuery, setDraftQuery] = useState('')
|
|
const [page, setPage] = useState(1)
|
|
|
|
const [selected, setSelected] = useState<ContactSubmission | null>(null)
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const result = await listContactSubmissions(
|
|
{
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
...(appliedQuery ? { q: appliedQuery } : {}),
|
|
},
|
|
controller.signal,
|
|
)
|
|
if (controller.signal.aborted) return
|
|
setData(result)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
setError(err instanceof ApiError ? err.message : t('website.contact.errorLoad'))
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [page, appliedQuery])
|
|
|
|
const totalPages = useMemo(() => {
|
|
const total = data?.total ?? 0
|
|
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
}, [data?.total])
|
|
|
|
const showingFrom = useMemo(() => {
|
|
if (!data || data.total === 0) return 0
|
|
return (page - 1) * PAGE_SIZE + 1
|
|
}, [data, page])
|
|
|
|
const showingTo = useMemo(() => {
|
|
if (!data) return 0
|
|
return Math.min(data.total, page * PAGE_SIZE)
|
|
}, [data, page])
|
|
|
|
function applyFilters() {
|
|
setPage(1)
|
|
setAppliedQuery(draftQuery.trim())
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftQuery('')
|
|
setAppliedQuery('')
|
|
setPage(1)
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Website', href: '/website' },
|
|
{ label: 'Contact Us Form' },
|
|
]}
|
|
/>
|
|
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<PageTitle en="CONTACT">{t('title.contactForm')}</PageTitle>
|
|
<p className={pageStyles.pageSubtitle}>{t('website.contact.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={filterStyles.filtersPanel}>
|
|
<div className={filterStyles.filtersTitle}>{t('website.contact.filters')}</div>
|
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={filterStyles.filtersInputs}>
|
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
|
<input
|
|
id="filter-contact-q"
|
|
value={draftQuery}
|
|
onChange={(e) => setDraftQuery(e.target.value)}
|
|
placeholder={t('website.contact.searchPlaceholder')}
|
|
aria-label={t('website.contact.search')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={filterStyles.filterActions}>
|
|
<button
|
|
type="submit"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
|
disabled={loading}
|
|
aria-label={t('website.contact.search')}
|
|
title={t('website.contact.search')}
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={loading}
|
|
aria-label={t('website.contact.clearFilters')}
|
|
title={t('website.contact.clearFilters')}
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className={styles.tablePanel}>
|
|
<div className={styles.tableWrap}>
|
|
<div className={styles.tableHeader}>
|
|
<div className={styles.tableHeaderTitle}>{t('website.contact.listTitle')}</div>
|
|
<div className={styles.meta}>
|
|
{data ? (
|
|
data.total > 0 ? (
|
|
<>
|
|
{t('website.contact.showing', {
|
|
from: showingFrom,
|
|
to: showingTo,
|
|
total: data.total,
|
|
})}
|
|
</>
|
|
) : (
|
|
t('website.contact.none')
|
|
)
|
|
) : (
|
|
' '
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
|
|
|
<div className={styles.tableScroll}>
|
|
<table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.th}>{t('website.contact.col.title')}</th>
|
|
<th className={styles.th}>{t('website.contact.col.name')}</th>
|
|
<th className={styles.th}>{t('website.contact.col.email')}</th>
|
|
<th className={styles.th}>{t('website.contact.col.cell')}</th>
|
|
<th className={styles.th}>{t('website.contact.col.date')}</th>
|
|
<th className={styles.th}>{t('website.contact.col.time')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
|
{t('website.contact.loading')}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading && data?.items.length === 0 && (
|
|
<EmptyTableRow
|
|
colSpan={COLUMN_COUNT}
|
|
icon={Mail}
|
|
message={t('website.contact.empty')}
|
|
/>
|
|
)}
|
|
|
|
{!loading &&
|
|
data?.items.map((item) => {
|
|
const { date, time } = formatDateTime(item.createdAt, locale)
|
|
return (
|
|
<tr
|
|
key={item.id}
|
|
className={styles.clickableRow}
|
|
onClick={() => setSelected(item)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
setSelected(item)
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={t('website.contact.viewSubmission', { name: item.name })}
|
|
>
|
|
<td className={styles.td}>{item.title}</td>
|
|
<td className={styles.td}>{item.name}</td>
|
|
<td className={styles.td}>{item.email ?? '—'}</td>
|
|
<td className={styles.td}>
|
|
{item.cellNumber ? formatCellForDisplay(item.cellNumber) : '—'}
|
|
</td>
|
|
<td className={styles.td}>{date}</td>
|
|
<td className={styles.td}>{time || '—'}</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className={styles.pagination}>
|
|
<div>
|
|
{t('website.contact.pageMeta', {
|
|
page,
|
|
totalPages,
|
|
pageSize: PAGE_SIZE,
|
|
total: data?.total ?? 0,
|
|
})}
|
|
</div>
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
disabled={loading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ContactSubmissionDetailModal
|
|
open={selected !== null}
|
|
submission={selected}
|
|
onClose={() => setSelected(null)}
|
|
/>
|
|
</main>
|
|
)
|
|
}
|