Improve product cards/pagination, Farsi locale fonts, and super-admin migrate UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-29 16:10:43 +03:30
co-authored by Cursor
parent 2e40d5eb4c
commit f5b2193ba1
108 changed files with 2421 additions and 812 deletions
+525 -47
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
Database,
Eraser,
FileText,
Globe,
Lock,
@@ -16,7 +18,16 @@ import {
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import type { BusinessesListResponse, BusinessListItem } from '../types/business'
import type {
BusinessesListResponse,
BusinessListItem,
MigrateEntityResult,
MigrateFromOldEntity,
MigrateFromOldResponse,
PurgeBusinessDataEntity,
PurgeBusinessDataResponse,
PurgeEntityResult,
} from '../types/business'
import type { BusinessCategory } from '../types/category'
import type { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api'
@@ -24,6 +35,8 @@ import {
addBusinessDomain,
createBusiness,
listBusinesses,
migrateBusinessFromOld,
purgeBusinessData,
removeBusiness,
setBusinessActive,
updateBusiness,
@@ -45,6 +58,7 @@ import {
updateBusinessPrimaryColor,
} from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
@@ -56,12 +70,99 @@ 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
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
product_categories: 'Product categories',
product: 'Products',
customer_categories: 'Customer categories',
customer: 'Customers',
blog_categories: 'Blog categories',
blog: 'Blogs',
portfolio_categories: 'Portfolio categories',
portfolio: 'Portfolios',
}
const DATA_ENTITY_GROUPS = [
{
title: 'Products',
items: [
['product_categories', 'Categories'],
['product', 'Products'],
],
},
{
title: 'Customers',
items: [
['customer_categories', 'Categories'],
['customer', 'Customers'],
],
},
{
title: 'Blogs',
items: [
['blog_categories', 'Categories'],
['blog', 'Blogs'],
],
},
{
title: 'Portfolios',
items: [
['portfolio_categories', 'Categories'],
['portfolio', 'Portfolios'],
],
},
] as const
function formatMigrateEntityResult(result: MigrateEntityResult): string {
if ('created' in result) {
const base = `${result.created} created, ${result.skipped} skipped (${result.total} total)`
const extras = [
result.imagesCopied != null ? `${result.imagesCopied} images copied` : null,
result.imagesResized != null && result.imagesResized > 0
? `${result.imagesResized} resized (≤1280px)`
: null,
result.titlesUpdated != null && result.titlesUpdated > 0
? `${result.titlesUpdated} titles updated`
: null,
result.imagesFailed != null && result.imagesFailed > 0
? `${result.imagesFailed} images failed`
: null,
result.skippedInvalidCell != null && result.skippedInvalidCell > 0
? `${result.skippedInvalidCell} invalid/missing cell`
: null,
result.skippedAlreadyLinked != null && result.skippedAlreadyLinked > 0
? `${result.skippedAlreadyLinked} already linked`
: null,
result.skippedCreateFailed != null && result.skippedCreateFailed > 0
? `${result.skippedCreateFailed} create failed`
: null,
].filter(Boolean)
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function formatPurgeEntityResult(result: PurgeEntityResult): string {
if ('deleted' in result) {
const extras = [
result.imagesDeleted != null ? `${result.imagesDeleted} images deleted` : null,
].filter(Boolean)
const base = `${result.deleted} deleted`
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
return {
product_categories: false,
product: false,
customer_categories: false,
customer: false,
blog_categories: false,
blog: false,
portfolio_categories: false,
portfolio: false,
}
}
export function BusinessesPage() {
@@ -91,12 +192,14 @@ export function BusinessesPage() {
)
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [domainOpen, setDomainOpen] = useState(false)
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
const [domainId, setDomainId] = useState<number | null>(null)
const [domainHost, setDomainHost] = useState('')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
@@ -108,11 +211,31 @@ export function BusinessesPage() {
const [createOwnerCell, setCreateOwnerCell] = useState('')
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [migrateOpen, setMigrateOpen] = useState(false)
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
const [migrateOldId, setMigrateOldId] = useState('')
const [migrateEntities, setMigrateEntities] = useState<Record<MigrateFromOldEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [migrateSubmitting, setMigrateSubmitting] = useState(false)
const [migrateError, setMigrateError] = useState('')
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
const [purgeEntities, setPurgeEntities] = useState<Record<PurgeBusinessDataEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [purgeSubmitting, setPurgeSubmitting] = useState(false)
const [purgeError, setPurgeError] = useState('')
const [purgeResult, setPurgeResult] = useState<PurgeBusinessDataResponse | null>(null)
async function fetchList() {
setLoading(true)
setError('')
@@ -171,8 +294,6 @@ export function BusinessesPage() {
return Math.max(1, Math.ceil(total / pageSize))
}, [data?.total, pageSize])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * pageSize + 1
@@ -206,6 +327,7 @@ export function BusinessesPage() {
setEditBusiness(b)
setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditError('')
setEditOpen(true)
setEditLoadingSettings(true)
@@ -216,7 +338,7 @@ export function BusinessesPage() {
})
.catch((err) => {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
setEditError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
})
.finally(() => {
if (!controller.signal.aborted) setEditLoadingSettings(false)
@@ -286,7 +408,7 @@ export function BusinessesPage() {
async function submitEdit() {
if (!editBusiness) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
@@ -306,7 +428,7 @@ export function BusinessesPage() {
showToast('Business updated.', 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update business.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
} finally {
setEditSubmitting(false)
}
@@ -316,16 +438,142 @@ export function BusinessesPage() {
setDomainBusiness(b)
setDomainId(b.domainId)
setDomainHost(b.domain ?? '')
setDomainError('')
setDomainOpen(true)
}
function openMigrate(b: BusinessListItem) {
setMigrateBusiness(b)
setMigrateOldId(b.oldBusinessId != null ? String(b.oldBusinessId) : '')
setMigrateEntities(emptyMigrateEntities())
setMigrateError('')
setMigrateResult(null)
setMigrateOpen(true)
}
const migrateSubmittingRef = useRef(false)
migrateSubmittingRef.current = migrateSubmitting
const closeMigrate = useCallback(() => {
if (migrateSubmittingRef.current) return
setMigrateOpen(false)
setMigrateBusiness(null)
setMigrateError('')
setMigrateResult(null)
}, [])
function selectMigrateEntity(entity: MigrateFromOldEntity) {
setMigrateResult(null)
setMigrateError('')
setMigrateEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitMigrate() {
if (!migrateBusiness) return
const oldBusinessId = Number(String(migrateOldId).trim())
if (!Number.isInteger(oldBusinessId) || oldBusinessId <= 0) {
setMigrateResult(null)
setMigrateError('Enter a valid old business id (positive integer).')
return
}
const entities = (Object.keys(migrateEntities) as MigrateFromOldEntity[]).filter(
(key) => migrateEntities[key],
)
if (entities.length === 0) {
setMigrateResult(null)
setMigrateError('Select a data type to migrate.')
return
}
setMigrateSubmitting(true)
setMigrateError('')
setMigrateResult(null)
try {
const result = await migrateBusinessFromOld(migrateBusiness.id, {
oldBusinessId,
entities,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === migrateBusiness.id
? { ...item, oldBusinessId: String(oldBusinessId) }
: item,
),
}
})
setMigrateResult(result)
} catch (err) {
setMigrateError(
err instanceof ApiError ? err.message : 'Unable to migrate data from old CMS.',
)
} finally {
setMigrateSubmitting(false)
}
}
function openPurge(b: BusinessListItem) {
setPurgeBusiness(b)
setPurgeEntities(emptyMigrateEntities())
setPurgeError('')
setPurgeResult(null)
setPurgeOpen(true)
}
const purgeSubmittingRef = useRef(false)
purgeSubmittingRef.current = purgeSubmitting
const closePurge = useCallback(() => {
if (purgeSubmittingRef.current) return
setPurgeOpen(false)
setPurgeBusiness(null)
setPurgeError('')
setPurgeResult(null)
}, [])
function selectPurgeEntity(entity: PurgeBusinessDataEntity) {
setPurgeResult(null)
setPurgeError('')
setPurgeEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitPurge() {
if (!purgeBusiness) return
const entities = (Object.keys(purgeEntities) as PurgeBusinessDataEntity[]).filter(
(key) => purgeEntities[key],
)
if (entities.length === 0) {
setPurgeResult(null)
setPurgeError('Select a data type to delete.')
return
}
setPurgeSubmitting(true)
setPurgeError('')
setPurgeResult(null)
try {
const result = await purgeBusinessData(purgeBusiness.id, { entities })
setPurgeResult(result)
showToast(result.message, 'success')
} catch (err) {
setPurgeError(
err instanceof ApiError ? err.message : 'Unable to delete selected business data.',
)
} finally {
setPurgeSubmitting(false)
}
}
async function submitDomain() {
if (!domainBusiness) return
const host = domainHost.trim()
if (!host) return
setDomainSubmitting(true)
setError('')
setDomainError('')
try {
if (domainId) {
await updateBusinessDomain(domainBusiness.id, domainId, { host })
@@ -366,7 +614,7 @@ export function BusinessesPage() {
setDomainBusiness(null)
setDomainId(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to save domain.')
setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally {
setDomainSubmitting(false)
}
@@ -381,6 +629,7 @@ export function BusinessesPage() {
setCreateOwnerLastName('')
setCreateOwnerCell('')
setCreateOwnerPassword('')
setCreateError('')
}
function openCreate() {
@@ -393,7 +642,7 @@ export function BusinessesPage() {
if (!ownerCellNumber) return
setCreateSubmitting(true)
setError('')
setCreateError('')
try {
await createBusiness({
name: createName.trim(),
@@ -410,7 +659,7 @@ export function BusinessesPage() {
showToast(`"${createName.trim()}" has been created.`, 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to create business.')
setCreateError(err instanceof ApiError ? err.message : 'Unable to create business.')
} finally {
setCreateSubmitting(false)
}
@@ -468,6 +717,13 @@ export function BusinessesPage() {
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
createOwnerPassword.length >= 8
const canSubmitMigrate =
Number.isInteger(Number(String(migrateOldId).trim())) &&
Number(String(migrateOldId).trim()) > 0 &&
Object.values(migrateEntities).some(Boolean)
const canSubmitPurge = Object.values(purgeEntities).some(Boolean)
return (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
@@ -681,6 +937,32 @@ export function BusinessesPage() {
>
<Users size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openMigrate(b)
}}
title="Migrate from old CMS"
aria-label="Migrate from old CMS"
>
<Database size={16} />
</button>
<button
type="button"
className={`${styles.controlBtn} ${styles.controlBtnDanger}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openPurge(b)
}}
title="Delete data"
aria-label="Delete business data"
>
<Eraser size={16} />
</button>
<button
type="button"
className={styles.controlBtn}
@@ -718,37 +1000,15 @@ export function BusinessesPage() {
<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} · {pageSize} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -769,8 +1029,14 @@ export function BusinessesPage() {
onClose={() => {
setEditOpen(false)
setEditBusiness(null)
setEditError('')
}}
>
{editError ? (
<p className={styles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="edit-name">Business name</label>
<input
@@ -820,8 +1086,14 @@ export function BusinessesPage() {
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
setDomainError('')
}}
>
{domainError ? (
<p className={styles.alertError} role="alert">
{domainError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="domain-host">Domain</label>
<input
@@ -853,6 +1125,207 @@ export function BusinessesPage() {
</div>
</Modal>
<Modal
open={migrateOpen}
title={
migrateBusiness
? `Migrate from old CMS — ${migrateBusiness.name}`
: 'Migrate from old CMS'
}
onClose={closeMigrate}
>
{migrateSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Migrating selected data This can take a moment.
</p>
) : null}
{migrateError ? (
<p className={styles.alertError} role="alert">
{migrateError}
</p>
) : null}
{migrateResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{migrateResult.message}</p>
{migrateResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(migrateResult.results) as MigrateFromOldEntity[]).map((key) => {
const item = migrateResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatMigrateEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Link this business to a legacy WillaEngine business id, then choose one
data type to migrate. Selecting blogs also migrates news under a top-level
News category. Selecting customers also migrates client categories when
needed.
</p>
<div className={styles.field}>
<label htmlFor="migrate-old-id">Old business id</label>
<input
id="migrate-old-id"
type="number"
min={1}
step={1}
value={String(migrateOldId)}
onChange={(e) => {
setMigrateOldId(e.target.value)
setMigrateResult(null)
setMigrateError('')
}}
placeholder="e.g. 2410"
disabled={migrateSubmitting}
/>
</div>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to migrate (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="migrate-entity"
checked={migrateEntities[value]}
onChange={() => selectMigrateEntity(value)}
disabled={migrateSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeMigrate}
disabled={migrateSubmitting}
>
{migrateResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitMigrate()}
disabled={migrateSubmitting || !canSubmitMigrate}
>
{migrateSubmitting
? 'Migrating…'
: migrateResult
? 'Migrate again'
: 'Save & migrate'}
</button>
</div>
</Modal>
<Modal
open={purgeOpen}
title={
purgeBusiness ? `Delete data — ${purgeBusiness.name}` : 'Delete data'
}
onClose={closePurge}
>
{purgeSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Deleting selected data This can take a moment.
</p>
) : null}
{purgeError ? (
<p className={styles.alertError} role="alert">
{purgeError}
</p>
) : null}
{purgeResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{purgeResult.message}</p>
{purgeResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(purgeResult.results) as PurgeBusinessDataEntity[]).map((key) => {
const item = purgeResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatPurgeEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Permanently remove one selected data type from this business so you can
migrate again. Deleting blogs or portfolios also removes their images
from storage. Deleting customers removes business links (not global user
accounts).
</p>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to delete (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="purge-entity"
checked={purgeEntities[value]}
onChange={() => selectPurgeEntity(value)}
disabled={purgeSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closePurge}
disabled={purgeSubmitting}
>
{purgeResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnDanger}`}
onClick={() => void submitPurge()}
disabled={purgeSubmitting || !canSubmitPurge}
>
{purgeSubmitting
? 'Deleting…'
: purgeResult
? 'Delete again'
: 'Delete selected data'}
</button>
</div>
</Modal>
<Modal
open={createOpen}
title="Add business"
@@ -862,6 +1335,11 @@ export function BusinessesPage() {
resetCreateForm()
}}
>
{createError ? (
<p className={styles.alertError} role="alert">
{createError}
</p>
) : null}
<div className={styles.formGrid}>
<div className={styles.field}>
<label htmlFor="create-name-en">Name (EN)</label>