mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Improve product cards/pagination, Farsi locale fonts, and super-admin migrate UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2e40d5eb4c
commit
f5b2193ba1
@@ -49,7 +49,8 @@ export function AddNewPortfolioPage() {
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [mainTextHtml, setMainTextHtml] = useState('')
|
||||
const [titleImage, setTitleImage] = useState<string | null>(null)
|
||||
@@ -98,7 +99,8 @@ export function AddNewPortfolioPage() {
|
||||
const portfolio = await getPortfolio(id!, controller.signal)
|
||||
const form = mapPortfolioApiToFormState(portfolio)
|
||||
setCategoryId(form.categoryId)
|
||||
setTitle(form.title)
|
||||
setTitleFa(form.titleFa)
|
||||
setTitleEn(form.titleEn)
|
||||
setAbstract(form.abstract)
|
||||
setMainTextHtml(form.mainTextHtml)
|
||||
setTitleImage(form.titleImage)
|
||||
@@ -140,7 +142,8 @@ export function AddNewPortfolioPage() {
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
abstract: abstract.trim(),
|
||||
mainTextHtml: resolvedMainTextHtml,
|
||||
categoryId: categoryId || undefined,
|
||||
@@ -267,18 +270,37 @@ export function AddNewPortfolioPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title">Title</label>
|
||||
<input
|
||||
id="portfolio-title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Portfolio title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title-fa">Title (FA)</label>
|
||||
<input
|
||||
id="portfolio-title-fa"
|
||||
name="titleFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder="عنوان فارسی"
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title-en">Title (EN)</label>
|
||||
<input
|
||||
id="portfolio-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="English title"
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumbnailField {
|
||||
align-self: start;
|
||||
}
|
||||
@@ -244,6 +251,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fieldFull,
|
||||
.fieldHalf,
|
||||
.fieldCategory,
|
||||
|
||||
@@ -63,6 +63,13 @@ export function BlogCategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -174,6 +181,10 @@ export function BlogCategoriesPage() {
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../services/blogService'
|
||||
import type { BlogDetail } from '../types/blog'
|
||||
import { BLOG_TYPE_OPTIONS } from '../types/blog'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogDetailsPage.module.css'
|
||||
|
||||
@@ -71,6 +72,11 @@ export function BlogDetailsPage() {
|
||||
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt)
|
||||
const typeLabel =
|
||||
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type
|
||||
const titleLocale = textLocaleAttrs(blog.title)
|
||||
const abstractLocale = textLocaleAttrs(blog.abstract)
|
||||
const contentLocale = textLocaleAttrs(
|
||||
blog.mainTextHtml?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
@@ -85,8 +91,26 @@ export function BlogDetailsPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{blog.title}</h2>
|
||||
{blog.abstract && <p className={pageStyles.pageSubtitle}>{blog.abstract}</p>}
|
||||
<h2
|
||||
className={[pageStyles.pageTitle, titleLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={titleLocale.lang}
|
||||
dir={titleLocale.dir}
|
||||
>
|
||||
{blog.title}
|
||||
</h2>
|
||||
{blog.abstract ? (
|
||||
<p
|
||||
className={[pageStyles.pageSubtitle, abstractLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
>
|
||||
{blog.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,11 +148,15 @@ export function BlogDetailsPage() {
|
||||
<div className={styles.contentPanel}>
|
||||
{blog.mainTextHtml ? (
|
||||
<div
|
||||
className={styles.content}
|
||||
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
|
||||
lang={contentLocale.lang}
|
||||
dir={contentLocale.dir}
|
||||
dangerouslySetInnerHTML={{ __html: blog.mainTextHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content}>No content yet.</p>
|
||||
<p className={styles.content} lang="en" dir="ltr">
|
||||
No content yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.authorRow}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { VariationsModal } from '../components/VariationsModal'
|
||||
import { TechnicalFormModal } from '../components/TechnicalFormModal'
|
||||
import { TechnicalFormAiPromptModal } from '../components/TechnicalFormAiPromptModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import type { Variation } from '../types/variation'
|
||||
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||
@@ -68,6 +69,7 @@ export function CategoriesPage() {
|
||||
const [technicalError, setTechnicalError] = useState('')
|
||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||
const [aiGenerating, setAiGenerating] = useState(false)
|
||||
const [techAiPromptOpen, setTechAiPromptOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -287,14 +289,19 @@ export function CategoriesPage() {
|
||||
return categoryTechnicalFields[categoryId] ?? []
|
||||
}
|
||||
|
||||
async function handleGenerateTechnicalForm() {
|
||||
function handleGenerateTechnicalFormClick() {
|
||||
setTechAiPromptOpen(true)
|
||||
}
|
||||
|
||||
async function handleGenerateTechnicalForm(hint?: string) {
|
||||
if (!technicalTarget) return
|
||||
|
||||
setTechAiPromptOpen(false)
|
||||
setTechnicalGenerating(true)
|
||||
setTechnicalError('')
|
||||
|
||||
try {
|
||||
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId)
|
||||
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId, hint)
|
||||
const drafts: TechnicalFormFieldDraft[] = suggested.map((field) => ({
|
||||
id: createId(),
|
||||
label: field.label,
|
||||
@@ -523,6 +530,17 @@ export function CategoriesPage() {
|
||||
}}
|
||||
onSave={() => void handleSaveTechnicalForm()}
|
||||
onGenerate={() => void handleGenerateTechnicalForm()}
|
||||
onGenerateClick={handleGenerateTechnicalFormClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{technicalTarget && (
|
||||
<TechnicalFormAiPromptModal
|
||||
open={techAiPromptOpen}
|
||||
categoryName={technicalTarget.categoryName}
|
||||
onClose={() => setTechAiPromptOpen(false)}
|
||||
onRun={(prompt) => handleGenerateTechnicalForm(prompt)}
|
||||
isRunning={technicalGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerName[dir='rtl'] {
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.emailCell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -139,7 +144,7 @@
|
||||
}
|
||||
|
||||
.toggleInActions {
|
||||
margin-right: 0;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -185,14 +190,22 @@
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AddCustomerModal } from '../components/AddCustomerModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { EditCustomerModal } from '../components/EditCustomerModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type CustomersListResponse,
|
||||
} from '../services/customerService'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import filterStyles from '../components/ListFiltersPanel.module.css'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomersPage.module.css'
|
||||
@@ -30,14 +32,6 @@ function formatDate(value: string) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(customer: BusinessCustomerListItem) {
|
||||
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
@@ -112,8 +106,6 @@ export function CustomersPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -365,7 +357,24 @@ export function CustomersPage() {
|
||||
className={!customer.isEnabled ? styles.inactiveRow : undefined}
|
||||
>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.customerName}>{displayName(customer)}</div>
|
||||
{(() => {
|
||||
const name = displayName(customer)
|
||||
const locale = textLocaleAttrs(name)
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.customerName,
|
||||
locale.className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={locale.lang}
|
||||
dir={locale.dir}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!customer.isEnabled && (
|
||||
<div className={styles.statusDisabled}>Disabled</div>
|
||||
)}
|
||||
@@ -383,15 +392,19 @@ export function CustomersPage() {
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<span className={styles.toggleInActions}>
|
||||
<ToggleSwitch
|
||||
checked={customer.isEnabled}
|
||||
disabled={togglingId === customer.id || removing}
|
||||
size="compact"
|
||||
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
|
||||
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
|
||||
/>
|
||||
</span>
|
||||
<Tooltip
|
||||
label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`}
|
||||
>
|
||||
<span className={styles.toggleInActions}>
|
||||
<ToggleSwitch
|
||||
checked={customer.isEnabled}
|
||||
disabled={togglingId === customer.id || removing}
|
||||
size="compact"
|
||||
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
|
||||
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit customer">
|
||||
<button
|
||||
type="button"
|
||||
@@ -442,37 +455,15 @@ export function CustomersPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,25 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
padding: 12px 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@@ -260,11 +260,19 @@ export function MyProductsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '}
|
||||
{totalProducts} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
disabled={isLoading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -267,14 +267,22 @@
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
import { OrderStepModal } from '../components/OrderStepModal'
|
||||
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -39,14 +40,6 @@ interface AppliedFilters {
|
||||
maxTotal?: number
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(order: Order) {
|
||||
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
@@ -202,8 +195,6 @@ export function OrdersPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -547,37 +538,15 @@ export function OrdersPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,13 @@ export function PortfolioCategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -174,6 +181,10 @@ export function PortfolioCategoriesPage() {
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
|
||||
@@ -151,7 +151,7 @@ export function PortfolioListPage() {
|
||||
<p className={styles.empty}>No portfolio items found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
<div className={pageStyles.gridCols4}>
|
||||
{portfolios.map((portfolio) => (
|
||||
<PortfolioCard
|
||||
key={portfolio.id}
|
||||
|
||||
@@ -119,12 +119,15 @@
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.nameFa:global(.faText) {
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -136,13 +139,26 @@
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 14px;
|
||||
font-weight: 300; /* IRANYekan Light */
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.description:global(.faText),
|
||||
.description:global(.faText) :where(*) {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-weight: 300;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.description p {
|
||||
margin-bottom: 0.75em;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.description p:last-child {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapProductApiToUi,
|
||||
} from '../services/productService'
|
||||
import type { Product } from '../types/product'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './ProductDetailsPage.module.css'
|
||||
|
||||
@@ -78,6 +79,14 @@ export function ProductDetailsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const nameEnLocale = textLocaleAttrs(product.nameEn)
|
||||
const nameFaLocale = textLocaleAttrs(product.nameFa)
|
||||
const summaryLocale = textLocaleAttrs(product.summary)
|
||||
const descriptionLocale = textLocaleAttrs(
|
||||
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||
)
|
||||
const categoryLocale = textLocaleAttrs(product.category)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -127,27 +136,69 @@ export function ProductDetailsPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
{product.category && <span className={styles.categoryChip}>{product.category}</span>}
|
||||
{product.category && (
|
||||
<span
|
||||
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
|
||||
lang={categoryLocale.lang}
|
||||
dir={categoryLocale.dir}
|
||||
>
|
||||
{product.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h1 className={styles.nameEn}>{product.nameEn}</h1>
|
||||
{product.nameFa && <p className={styles.nameFa}>{product.nameFa}</p>}
|
||||
<h1
|
||||
className={[styles.nameEn, nameEnLocale.className].filter(Boolean).join(' ')}
|
||||
lang={nameEnLocale.lang}
|
||||
dir={nameEnLocale.dir}
|
||||
>
|
||||
{product.nameEn}
|
||||
</h1>
|
||||
{product.nameFa && (
|
||||
<p
|
||||
className={[styles.nameFa, nameFaLocale.className].filter(Boolean).join(' ')}
|
||||
lang={nameFaLocale.lang}
|
||||
dir={nameFaLocale.dir}
|
||||
>
|
||||
{product.nameFa}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{product.summary && <p className={styles.summary}>{product.summary}</p>}
|
||||
{product.summary && (
|
||||
<p
|
||||
className={[styles.summary, summaryLocale.className].filter(Boolean).join(' ')}
|
||||
lang={summaryLocale.lang}
|
||||
dir={summaryLocale.dir}
|
||||
>
|
||||
{product.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{product.description && (
|
||||
<div
|
||||
className={styles.description}
|
||||
className={[styles.description, descriptionLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={descriptionLocale.lang}
|
||||
dir={descriptionLocale.dir}
|
||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{product.tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{product.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{product.tags.map((tag) => {
|
||||
const tagLocale = textLocaleAttrs(tag)
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className={[styles.tag, tagLocale.className].filter(Boolean).join(' ')}
|
||||
lang={tagLocale.lang}
|
||||
dir={tagLocale.dir}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -31,14 +32,6 @@ interface AppliedFilters {
|
||||
maxTotal?: number
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(card: ShoppingCard) {
|
||||
const name = [card.customer.firstName, card.customer.lastName]
|
||||
.filter(Boolean)
|
||||
@@ -123,8 +116,6 @@ export function ShoppingCardsPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -403,37 +394,15 @@ export function ShoppingCardsPage() {
|
||||
|
||||
<div className={tableStyles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={tableStyles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,14 +88,18 @@
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { RotateCcw, Search } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -18,14 +19,6 @@ import styles from './WebsiteContactPage.module.css'
|
||||
const PAGE_SIZE = 20
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
@@ -83,8 +76,6 @@ export function WebsiteContactPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -252,37 +243,15 @@ export function WebsiteContactPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user