diff --git a/apps/business/src/pages/CategoriesPage.tsx b/apps/business/src/pages/CategoriesPage.tsx
index a487035..89f1a3d 100644
--- a/apps/business/src/pages/CategoriesPage.tsx
+++ b/apps/business/src/pages/CategoriesPage.tsx
@@ -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 && (
+
setTechAiPromptOpen(false)}
+ onRun={(prompt) => handleGenerateTechnicalForm(prompt)}
+ isRunning={technicalGenerating}
/>
)}
diff --git a/apps/business/src/pages/CustomersPage.module.css b/apps/business/src/pages/CustomersPage.module.css
index 7e3a24e..baa137e 100644
--- a/apps/business/src/pages/CustomersPage.module.css
+++ b/apps/business/src/pages/CustomersPage.module.css
@@ -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;
diff --git a/apps/business/src/pages/CustomersPage.tsx b/apps/business/src/pages/CustomersPage.tsx
index 6fbffdf..d3b80bb 100644
--- a/apps/business/src/pages/CustomersPage.tsx
+++ b/apps/business/src/pages/CustomersPage.tsx
@@ -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}
>
- {displayName(customer)}
+ {(() => {
+ const name = displayName(customer)
+ const locale = textLocaleAttrs(name)
+ return (
+
+ {name}
+
+ )
+ })()}
{!customer.isEnabled && (
Disabled
)}
@@ -383,15 +392,19 @@ export function CustomersPage() {
|
-
- void handleToggleEnabled(customer, isEnabled)}
- />
-
+
+
+ void handleToggleEnabled(customer, isEnabled)}
+ />
+
+
diff --git a/apps/business/src/pages/MyProductsPage.module.css b/apps/business/src/pages/MyProductsPage.module.css
index 8ce64d8..2deded0 100644
--- a/apps/business/src/pages/MyProductsPage.module.css
+++ b/apps/business/src/pages/MyProductsPage.module.css
@@ -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);
diff --git a/apps/business/src/pages/MyProductsPage.tsx b/apps/business/src/pages/MyProductsPage.tsx
index a536dce..35ef6e8 100644
--- a/apps/business/src/pages/MyProductsPage.tsx
+++ b/apps/business/src/pages/MyProductsPage.tsx
@@ -260,11 +260,19 @@ export function MyProductsPage() {
))}
-
+
+
+ Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '}
+ {totalProducts} total
+
+
+
>
)}
diff --git a/apps/business/src/pages/OrdersPage.module.css b/apps/business/src/pages/OrdersPage.module.css
index b9e3022..2da82c6 100644
--- a/apps/business/src/pages/OrdersPage.module.css
+++ b/apps/business/src/pages/OrdersPage.module.css
@@ -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;
diff --git a/apps/business/src/pages/OrdersPage.tsx b/apps/business/src/pages/OrdersPage.tsx
index d681ac3..4e5e120 100644
--- a/apps/business/src/pages/OrdersPage.tsx
+++ b/apps/business/src/pages/OrdersPage.tsx
@@ -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() {
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+
diff --git a/apps/business/src/pages/PortfolioCategoriesPage.tsx b/apps/business/src/pages/PortfolioCategoriesPage.tsx
index 1e198cd..621c7c1 100644
--- a/apps/business/src/pages/PortfolioCategoriesPage.tsx
+++ b/apps/business/src/pages/PortfolioCategoriesPage.tsx
@@ -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)
diff --git a/apps/business/src/pages/PortfolioListPage.tsx b/apps/business/src/pages/PortfolioListPage.tsx
index c047a09..4a016e8 100644
--- a/apps/business/src/pages/PortfolioListPage.tsx
+++ b/apps/business/src/pages/PortfolioListPage.tsx
@@ -151,7 +151,7 @@ export function PortfolioListPage() {
No portfolio items found.
) : (
<>
-
+
{portfolios.map((portfolio) => (
]+>/g, ' ') ?? '',
+ )
+ const categoryLocale = textLocaleAttrs(product.category)
+
return (
- {product.category && {product.category}}
+ {product.category && (
+
+ {product.category}
+
+ )}
- {product.nameEn}
- {product.nameFa && {product.nameFa} }
+
+ {product.nameEn}
+
+ {product.nameFa && (
+
+ {product.nameFa}
+
+ )}
- {product.summary && {product.summary} }
+ {product.summary && (
+
+ {product.summary}
+
+ )}
{product.description && (
)}
{product.tags.length > 0 && (
- {product.tags.map((tag) => (
-
- {tag}
-
- ))}
+ {product.tags.map((tag) => {
+ const tagLocale = textLocaleAttrs(tag)
+ return (
+
+ {tag}
+
+ )
+ })}
)}
diff --git a/apps/business/src/pages/ShoppingCardsPage.tsx b/apps/business/src/pages/ShoppingCardsPage.tsx
index fc5218a..e5bd80b 100644
--- a/apps/business/src/pages/ShoppingCardsPage.tsx
+++ b/apps/business/src/pages/ShoppingCardsPage.tsx
@@ -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() {
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+
diff --git a/apps/business/src/pages/WebsiteContactPage.module.css b/apps/business/src/pages/WebsiteContactPage.module.css
index ea21399..74a3593 100644
--- a/apps/business/src/pages/WebsiteContactPage.module.css
+++ b/apps/business/src/pages/WebsiteContactPage.module.css
@@ -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;
diff --git a/apps/business/src/pages/WebsiteContactPage.tsx b/apps/business/src/pages/WebsiteContactPage.tsx
index be492bc..2298e34 100644
--- a/apps/business/src/pages/WebsiteContactPage.tsx
+++ b/apps/business/src/pages/WebsiteContactPage.tsx
@@ -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() {
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+
diff --git a/apps/business/src/services/portfolioService.ts b/apps/business/src/services/portfolioService.ts
index d5c760f..3f6c604 100644
--- a/apps/business/src/services/portfolioService.ts
+++ b/apps/business/src/services/portfolioService.ts
@@ -9,6 +9,8 @@ export interface PortfolioApi {
id: string
businessId: string
title: string
+ titleFa: string
+ titleEn: string | null
slug: string
abstract: string
mainTextHtml: string
@@ -51,7 +53,10 @@ function businessPath(suffix = '') {
}
export interface CreatePortfolioPayload {
- title: string
+ titleFa: string
+ titleEn?: string
+ /** @deprecated Prefer titleFa */
+ title?: string
abstract?: string
mainTextHtml?: string
categoryId?: string
@@ -63,6 +68,9 @@ export interface CreatePortfolioPayload {
}
export interface UpdatePortfolioPayload {
+ titleFa?: string
+ titleEn?: string | null
+ /** @deprecated Prefer titleFa */
title?: string
abstract?: string | null
mainTextHtml?: string | null
@@ -87,7 +95,9 @@ export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string |
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
return {
id: portfolio.id,
- title: portfolio.title,
+ title: portfolio.titleFa || portfolio.title,
+ titleFa: portfolio.titleFa || portfolio.title,
+ titleEn: portfolio.titleEn ?? null,
slug: portfolio.slug,
abstract: portfolio.abstract,
status: portfolio.status,
@@ -134,7 +144,8 @@ export function mapPortfolioDetailFromApi(portfolio: PortfolioApi): PortfolioDet
export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
return {
- title: portfolio.title,
+ titleFa: portfolio.titleFa || portfolio.title,
+ titleEn: portfolio.titleEn ?? '',
abstract: portfolio.abstract,
mainTextHtml: portfolio.mainTextHtml,
categoryId: portfolio.categoryId ?? '',
diff --git a/apps/business/src/services/productService.ts b/apps/business/src/services/productService.ts
index 4a2244c..da8109a 100644
--- a/apps/business/src/services/productService.ts
+++ b/apps/business/src/services/productService.ts
@@ -2,7 +2,7 @@ import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { Product } from '../types/product'
-export const PRODUCTS_PER_PAGE = 12
+export const PRODUCTS_PER_PAGE = 24
export interface ProductApi {
id: string
diff --git a/apps/business/src/types/portfolio.ts b/apps/business/src/types/portfolio.ts
index 17f2a2b..5ceea80 100644
--- a/apps/business/src/types/portfolio.ts
+++ b/apps/business/src/types/portfolio.ts
@@ -8,6 +8,8 @@ export interface PortfolioGalleryItem {
export interface Portfolio {
id: string
title: string
+ titleFa: string
+ titleEn: string | null
slug: string
abstract: string
status: PortfolioStatus
diff --git a/apps/business/src/utils/textLocale.ts b/apps/business/src/utils/textLocale.ts
new file mode 100644
index 0000000..6bc2174
--- /dev/null
+++ b/apps/business/src/utils/textLocale.ts
@@ -0,0 +1,22 @@
+/** Arabic / Persian script ranges (including presentation forms). */
+const RTL_SCRIPT_RE =
+ /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
+
+export function isRtlScript(text: string | null | undefined): boolean {
+ if (!text) return false
+ return RTL_SCRIPT_RE.test(text)
+}
+
+export type TextLocaleAttrs = {
+ lang: 'fa' | 'en'
+ dir: 'rtl' | 'ltr'
+ className: string | undefined
+}
+
+/** Font + direction attrs for mixed FA/EN content fields. */
+export function textLocaleAttrs(text: string | null | undefined): TextLocaleAttrs {
+ if (isRtlScript(text)) {
+ return { lang: 'fa', dir: 'rtl', className: 'faText' }
+ }
+ return { lang: 'en', dir: 'ltr', className: undefined }
+}
diff --git a/apps/customer/src/components/OrderItemsModal.tsx b/apps/customer/src/components/OrderItemsModal.tsx
index 3be1d72..3a62474 100644
--- a/apps/customer/src/components/OrderItemsModal.tsx
+++ b/apps/customer/src/components/OrderItemsModal.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
@@ -59,7 +60,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
const itemCount = totalQuantity(order)
- return (
+ return createPortal(
+ ,
+ document.body,
)
}
diff --git a/apps/customer/src/pages/FavoritesPage.tsx b/apps/customer/src/pages/FavoritesPage.tsx
index 3c96b89..d6bb9e7 100644
--- a/apps/customer/src/pages/FavoritesPage.tsx
+++ b/apps/customer/src/pages/FavoritesPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react'
-import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
+import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { ApiError, isAbortError } from '../lib/api'
import {
@@ -113,25 +113,12 @@ export function FavoritesPage() {
)}
{data && data.total > PAGE_SIZE && (
-
- setPage((p) => Math.max(1, p - 1))}
- >
- Previous
-
-
- Page {page} of {Math.max(1, Math.ceil(data.total / data.pageSize))}
-
- = Math.ceil(data.total / data.pageSize)}
- onClick={() => setPage((p) => p + 1)}
- >
- Next
-
-
+
)}
)
diff --git a/apps/customer/src/pages/OrdersPage.tsx b/apps/customer/src/pages/OrdersPage.tsx
index e05d5a0..ab167dd 100644
--- a/apps/customer/src/pages/OrdersPage.tsx
+++ b/apps/customer/src/pages/OrdersPage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
-import { Breadcrumbs } from '@meshkee/dashboard-ui'
+import { Breadcrumbs, Pagination } from '@meshkee/dashboard-ui'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow'
import { ApiError, isAbortError } from '../lib/api'
@@ -15,14 +15,6 @@ import styles from './OrdersPage.module.css'
const PAGE_SIZE = 20
const COLUMN_COUNT = 7
-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
-}
-
export function OrdersPage() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
@@ -58,8 +50,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
@@ -155,37 +145,15 @@ export function OrdersPage() {
{data && data.total > PAGE_SIZE && (
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {PAGE_SIZE} per page · {data.total} total
+
)}
diff --git a/apps/super-admin/public/fonts/iranyekan/IRANYekanWebLight.ttf b/apps/super-admin/public/fonts/iranyekan/IRANYekanWebLight.ttf
new file mode 100755
index 0000000..4431c6f
Binary files /dev/null and b/apps/super-admin/public/fonts/iranyekan/IRANYekanWebLight.ttf differ
diff --git a/apps/super-admin/public/fonts/iranyekan/IRANYekanWebRegular.ttf b/apps/super-admin/public/fonts/iranyekan/IRANYekanWebRegular.ttf
new file mode 100755
index 0000000..77dec5f
Binary files /dev/null and b/apps/super-admin/public/fonts/iranyekan/IRANYekanWebRegular.ttf differ
diff --git a/apps/super-admin/src/App.tsx b/apps/super-admin/src/App.tsx
index c295ee6..42a98e0 100644
--- a/apps/super-admin/src/App.tsx
+++ b/apps/super-admin/src/App.tsx
@@ -1,10 +1,11 @@
-import { BrowserRouter, Routes, Route } from 'react-router-dom'
+import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { ToastProvider } from './context/ToastContext'
import { AdminDomainGuard } from './components/AdminDomainGuard'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
+import { RouteErrorBoundary } from './components/RouteErrorBoundary'
import { HomePage } from './pages/HomePage'
import { BusinessesPage } from './pages/BusinessesPage'
import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage'
@@ -17,41 +18,65 @@ import { PublicInvoicePage } from './pages/PublicInvoicePage'
import { ProfilePage } from './pages/ProfilePage'
import { LoginPage } from './pages/LoginPage'
+function ProtectedPages() {
+ return (
+
+
+
+ )
+}
+
function App() {
return (
-
-
-
-
-
- } />
+
+
+
+
+ {/* Host-agnostic: can be served from meshkee.com or other public domains */}
+ } />
+ }>
}>
} />
}>
}>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- }
- />
- } />
+ }>
+ } />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ } />
+ }
+ />
+ }
+ />
+ } />
+
-
-
-
-
-
+
+
+
+
+
)
}
diff --git a/apps/super-admin/src/components/AdminDomainGuard.tsx b/apps/super-admin/src/components/AdminDomainGuard.tsx
index c84017e..3541b6c 100644
--- a/apps/super-admin/src/components/AdminDomainGuard.tsx
+++ b/apps/super-admin/src/components/AdminDomainGuard.tsx
@@ -1,10 +1,11 @@
-import type { ReactNode } from 'react'
+import { Outlet } from 'react-router-dom'
import { getAdminDomain, isAllowedAdminHost } from '../lib/config'
import styles from './AdminDomainGuard.module.css'
-export function AdminDomainGuard({ children }: { children: ReactNode }) {
+/** Protects manage-only routes. Public invoice routes stay outside this guard. */
+export function AdminDomainGuard() {
if (isAllowedAdminHost()) {
- return children
+ return
}
const expectedDomain = getAdminDomain()
diff --git a/apps/super-admin/src/components/ConfirmDeleteModal.module.css b/apps/super-admin/src/components/ConfirmDeleteModal.module.css
index bf6b8bb..a10ae73 100644
--- a/apps/super-admin/src/components/ConfirmDeleteModal.module.css
+++ b/apps/super-admin/src/components/ConfirmDeleteModal.module.css
@@ -1,13 +1,13 @@
.overlay {
position: fixed;
inset: 0;
- background: rgba(127, 29, 29, 0.2);
- backdrop-filter: blur(6px);
- -webkit-backdrop-filter: blur(6px);
+ background: rgba(127, 29, 29, 0.28);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
- z-index: 200;
+ z-index: 300;
padding: 20px;
}
diff --git a/apps/super-admin/src/components/ConfirmDeleteModal.tsx b/apps/super-admin/src/components/ConfirmDeleteModal.tsx
index 4ba2962..daa3385 100644
--- a/apps/super-admin/src/components/ConfirmDeleteModal.tsx
+++ b/apps/super-admin/src/components/ConfirmDeleteModal.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css'
@@ -41,13 +42,18 @@ export function ConfirmDeleteModal({
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
+ const prevOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
- return () => document.removeEventListener('keydown', onKey)
+ return () => {
+ document.body.style.overflow = prevOverflow
+ document.removeEventListener('keydown', onKey)
+ }
}, [mounted, closing, onCancel])
if (!mounted) return null
- return (
+ return createPortal(
-
+ ,
+ document.body,
)
}
diff --git a/apps/super-admin/src/components/Modal.module.css b/apps/super-admin/src/components/Modal.module.css
index 9e6ee46..f84dd4d 100644
--- a/apps/super-admin/src/components/Modal.module.css
+++ b/apps/super-admin/src/components/Modal.module.css
@@ -1,9 +1,9 @@
.overlay {
position: fixed;
inset: 0;
- background: rgba(15, 23, 42, 0.25);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
+ background: rgba(15, 23, 42, 0.32);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
@@ -73,4 +73,3 @@
flex: 1;
min-height: 0;
}
-
diff --git a/apps/super-admin/src/components/Modal.tsx b/apps/super-admin/src/components/Modal.tsx
index b1ccbcb..c024b80 100644
--- a/apps/super-admin/src/components/Modal.tsx
+++ b/apps/super-admin/src/components/Modal.tsx
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
+import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import styles from './Modal.module.css'
@@ -18,17 +19,29 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
+ const prevOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
- return () => document.removeEventListener('keydown', onKey)
+ return () => {
+ document.body.style.overflow = prevOverflow
+ document.removeEventListener('keydown', onKey)
+ }
}, [open, onClose])
if (!open) return null
const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
- return (
-
- e.stopPropagation()}>
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+ e.stopPropagation()}>
{title}
@@ -37,7 +50,7 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
{children}
-
+ ,
+ document.body,
)
}
-
diff --git a/apps/super-admin/src/components/RouteErrorBoundary.tsx b/apps/super-admin/src/components/RouteErrorBoundary.tsx
new file mode 100644
index 0000000..1193f6b
--- /dev/null
+++ b/apps/super-admin/src/components/RouteErrorBoundary.tsx
@@ -0,0 +1,56 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react'
+
+interface Props {
+ children: ReactNode
+}
+
+interface State {
+ error: Error | null
+}
+
+export class RouteErrorBoundary extends Component {
+ state: State = { error: null }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('Route render error:', error, info.componentStack)
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ Something went wrong
+ {this.state.error.message}
+
+ {this.state.error.stack}
+
+ {
+ this.setState({ error: null })
+ window.location.assign('/businesses')
+ }}
+ >
+ Reload businesses
+
+
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/apps/super-admin/src/fonts/iranyekan.css b/apps/super-admin/src/fonts/iranyekan.css
index b2d6b98..b25e4d7 100644
--- a/apps/super-admin/src/fonts/iranyekan.css
+++ b/apps/super-admin/src/fonts/iranyekan.css
@@ -1,9 +1,7 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
@font-face {
font-family: 'IRANYekan';
- src:
- url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
- url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
+ src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
@@ -11,30 +9,8 @@
@font-face {
font-family: 'IRANYekan';
- src:
- url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
- url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
+ src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
-
-@font-face {
- font-family: 'IRANYekan';
- src:
- url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
- url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
- font-weight: 500;
- font-style: normal;
- font-display: swap;
-}
-
-@font-face {
- font-family: 'IRANYekan';
- src:
- url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
- url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
- font-weight: 700;
- font-style: normal;
- font-display: swap;
-}
diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css
index fca460a..8cd862b 100644
--- a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css
+++ b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css
@@ -27,30 +27,76 @@
.statusChip {
display: inline-flex;
align-items: center;
- padding: 3px 8px;
+ max-width: 100%;
+ padding: 4px 10px;
border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border: 1px solid transparent;
+ transition: filter 0.2s;
+}
+
+.statusChipBtn {
+ cursor: pointer;
+ background: none;
+}
+
+.statusChipBtn:hover {
+ filter: brightness(0.95);
+}
+
+.statusChipBtn:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+.statusChipBtn:disabled {
+ cursor: wait;
+ opacity: 0.7;
+}
+
+.statusOptionList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.statusOption {
+ min-height: 32px;
+ padding: 6px 12px;
font-size: 12px;
- font-weight: 600;
}
-.status_draft {
- background: rgba(148, 163, 184, 0.18);
- color: #475569;
+.statusOptionSelected {
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
+.status_draft,
.status_issued {
- background: rgba(var(--primary-rgb) / 0.12);
- color: var(--primary);
+ color: #0f172a;
+ background: rgba(15, 23, 42, 0.08);
+ border-color: rgba(15, 23, 42, 0.18);
+}
+
+.status_approved {
+ color: #0e7490;
+ background: rgba(6, 182, 212, 0.14);
+ border-color: rgba(6, 182, 212, 0.32);
}
.status_paid {
- background: rgba(34, 197, 94, 0.12);
color: #15803d;
+ background: rgba(34, 197, 94, 0.14);
+ border-color: rgba(34, 197, 94, 0.32);
}
.status_cancelled {
- background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
+ background: rgba(239, 68, 68, 0.12);
+ border-color: rgba(239, 68, 68, 0.28);
}
.itemTitle {
@@ -314,6 +360,10 @@
margin-bottom: 2px;
}
+.detailMeta .statusChipBtn {
+ margin-top: 4px;
+}
+
.detailNotes {
margin: 0 0 14px;
font-size: 13px;
diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
index 85d8f21..7342ae1 100644
--- a/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
+++ b/apps/super-admin/src/pages/BusinessInvoicesPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
-import { ArrowLeft, Copy, Eye, FilePlus2, Trash2 } from 'lucide-react'
+import { ArrowLeft, Copy, Eye, FilePlus2, Pencil, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal'
import { isEmptyRichText } from '../components/RichTextEditor'
@@ -15,11 +15,12 @@ import {
} from '../services/invoiceService'
import type { Invoice, InvoiceStatus } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice'
+import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './BusinessInvoicesPage.module.css'
-const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'paid', 'cancelled']
+const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'approved', 'paid', 'cancelled']
function formatDate(value: string) {
const d = new Date(value)
@@ -35,6 +36,21 @@ function statusLabel(status: InvoiceStatus) {
return status.charAt(0).toUpperCase() + status.slice(1)
}
+function statusClass(status: InvoiceStatus) {
+ return styles[`status_${status}` as keyof typeof styles] ?? styles.status_draft
+}
+
+function canEditInvoice(status: InvoiceStatus) {
+ return status !== 'approved'
+}
+
+function allowedStatusOptions(current: InvoiceStatus): InvoiceStatus[] {
+ if (current !== 'approved') return STATUS_OPTIONS
+ return STATUS_OPTIONS.filter(
+ (option) => option === 'approved' || option === 'paid' || option === 'cancelled',
+ )
+}
+
export function BusinessInvoicesPage() {
const { businessId = '' } = useParams()
const navigate = useNavigate()
@@ -49,7 +65,8 @@ export function BusinessInvoicesPage() {
const [total, setTotal] = useState(0)
const [detailInvoice, setDetailInvoice] = useState(null)
- const [statusUpdating, setStatusUpdating] = useState(false)
+ const [statusTarget, setStatusTarget] = useState(null)
+ const [statusUpdating, setStatusUpdating] = useState(null)
const [removeTarget, setRemoveTarget] = useState(null)
async function reload(signal?: AbortSignal) {
@@ -80,20 +97,30 @@ export function BusinessInvoicesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [businessId, page])
- async function handleStatusChange(next: InvoiceStatus) {
- if (!detailInvoice) return
- setStatusUpdating(true)
+ function openStatusModal(invoice: Invoice) {
+ setStatusTarget(invoice)
+ }
+
+ async function handleStatusSelect(next: InvoiceStatus) {
+ if (!statusTarget || statusUpdating) return
+ if (next === statusTarget.status) {
+ setStatusTarget(null)
+ return
+ }
+
+ setStatusUpdating(next)
try {
- const updated = await updateBusinessInvoiceStatus(businessId, detailInvoice.id, {
+ const updated = await updateBusinessInvoiceStatus(businessId, statusTarget.id, {
status: next,
})
- setDetailInvoice(updated)
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
+ setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
showToast('Invoice status updated.', 'success')
+ setStatusTarget(null)
} catch (err) {
showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error')
} finally {
- setStatusUpdating(false)
+ setStatusUpdating(null)
}
}
@@ -191,9 +218,15 @@ export function BusinessInvoicesPage() {
|
{formatDate(invoice.issuedAt)} |
-
+ openStatusModal(invoice)}
+ title="Change status"
+ aria-label={`Change status (${statusLabel(invoice.status)})`}
+ >
{statusLabel(invoice.status)}
-
+
|
{formatIrtPrice(invoice.total ?? 0)} |
@@ -229,6 +262,30 @@ export function BusinessInvoicesPage() {
>
+
+
+ navigate(`/businesses/${businessId}/invoices/${invoice.id}/edit`)
+ }
+ title={canEditInvoice(invoice.status) ? 'Edit' : undefined}
+ aria-label={
+ canEditInvoice(invoice.status)
+ ? 'Edit invoice'
+ : 'Edit invoice (disabled — approved)'
+ }
+ >
+
+
+
1 ? (
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- >
- Prev
-
- = totalPages || loading}
- onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
- >
- Next
-
+ Page {page} / {totalPages} · 20 per page · {total} total
+
) : null}
@@ -287,17 +333,15 @@ export function BusinessInvoicesPage() {
Status
-
+ {statusLabel(detailInvoice.status)}
+
Total
@@ -396,6 +440,40 @@ export function BusinessInvoicesPage() {
) : null}
+ {
+ if (!statusUpdating) setStatusTarget(null)
+ }}
+ >
+ {statusTarget ? (
+
+ {allowedStatusOptions(statusTarget.status).map((option) => {
+ const selected = statusTarget.status === option
+ const saving = statusUpdating === option
+ return (
+ void handleStatusSelect(option)}
+ >
+ {saving ? 'Saving…' : statusLabel(option)}
+
+ )
+ })}
+
+ ) : null}
+
+
= {
+ 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 {
+ 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(null)
const [domainId, setDomainId] = useState(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(null)
const [togglingId, setTogglingId] = useState(null)
const [savingColorId, setSavingColorId] = useState(null)
+ const [migrateOpen, setMigrateOpen] = useState(false)
+ const [migrateBusiness, setMigrateBusiness] = useState(null)
+ const [migrateOldId, setMigrateOldId] = useState('')
+ const [migrateEntities, setMigrateEntities] = useState>(
+ () => emptyMigrateEntities(),
+ )
+ const [migrateSubmitting, setMigrateSubmitting] = useState(false)
+ const [migrateError, setMigrateError] = useState('')
+ const [migrateResult, setMigrateResult] = useState(null)
+
+ const [purgeOpen, setPurgeOpen] = useState(false)
+ const [purgeBusiness, setPurgeBusiness] = useState(null)
+ const [purgeEntities, setPurgeEntities] = useState>(
+ () => emptyMigrateEntities(),
+ )
+ const [purgeSubmitting, setPurgeSubmitting] = useState(false)
+ const [purgeError, setPurgeError] = useState('')
+ const [purgeResult, setPurgeResult] = useState(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 (
@@ -681,6 +937,32 @@ export function BusinessesPage() {
>
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ openMigrate(b)
+ }}
+ title="Migrate from old CMS"
+ aria-label="Migrate from old CMS"
+ >
+
+
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ openPurge(b)
+ }}
+ title="Delete data"
+ aria-label="Delete business data"
+ >
+
+
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
+
@@ -769,8 +1029,14 @@ export function BusinessesPage() {
onClose={() => {
setEditOpen(false)
setEditBusiness(null)
+ setEditError('')
}}
>
+ {editError ? (
+
+ {editError}
+
+ ) : null}
+ {domainError ? (
+
+ {domainError}
+
+ ) : null}
+
+ {migrateSubmitting ? (
+
+ Migrating selected data… This can take a moment.
+
+ ) : null}
+ {migrateError ? (
+
+ {migrateError}
+
+ ) : null}
+ {migrateResult ? (
+
+ {migrateResult.message}
+ {migrateResult.results ? (
+
+ {(Object.keys(migrateResult.results) as MigrateFromOldEntity[]).map((key) => {
+ const item = migrateResult.results?.[key]
+ if (!item) return null
+ return (
+ -
+ {MIGRATE_ENTITY_LABELS[key]}:{' '}
+ {formatMigrateEntityResult(item)}
+
+ )
+ })}
+
+ ) : null}
+
+ ) : null}
+
+ 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.
+
+
+
+ {
+ setMigrateOldId(e.target.value)
+ setMigrateResult(null)
+ setMigrateError('')
+ }}
+ placeholder="e.g. 2410"
+ disabled={migrateSubmitting}
+ />
+
+
+ Data to migrate (one)
+
+ {DATA_ENTITY_GROUPS.map((group) => (
+
+ {group.title}
+
+ {group.items.map(([value, label]) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ {migrateResult ? 'Close' : 'Cancel'}
+
+ void submitMigrate()}
+ disabled={migrateSubmitting || !canSubmitMigrate}
+ >
+ {migrateSubmitting
+ ? 'Migrating…'
+ : migrateResult
+ ? 'Migrate again'
+ : 'Save & migrate'}
+
+
+
+
+
+ {purgeSubmitting ? (
+
+ Deleting selected data… This can take a moment.
+
+ ) : null}
+ {purgeError ? (
+
+ {purgeError}
+
+ ) : null}
+ {purgeResult ? (
+
+ {purgeResult.message}
+ {purgeResult.results ? (
+
+ {(Object.keys(purgeResult.results) as PurgeBusinessDataEntity[]).map((key) => {
+ const item = purgeResult.results?.[key]
+ if (!item) return null
+ return (
+ -
+ {MIGRATE_ENTITY_LABELS[key]}:{' '}
+ {formatPurgeEntityResult(item)}
+
+ )
+ })}
+
+ ) : null}
+
+ ) : null}
+
+ 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).
+
+
+ Data to delete (one)
+
+ {DATA_ENTITY_GROUPS.map((group) => (
+
+ {group.title}
+
+ {group.items.map(([value, label]) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ {purgeResult ? 'Close' : 'Cancel'}
+
+ void submitPurge()}
+ disabled={purgeSubmitting || !canSubmitPurge}
+ >
+ {purgeSubmitting
+ ? 'Deleting…'
+ : purgeResult
+ ? 'Delete again'
+ : 'Delete selected data'}
+
+
+
+
+ {createError ? (
+
+ {createError}
+
+ ) : null}
diff --git a/apps/super-admin/src/pages/IssueInvoicePage.tsx b/apps/super-admin/src/pages/IssueInvoicePage.tsx
index ef10beb..e4245ab 100644
--- a/apps/super-admin/src/pages/IssueInvoicePage.tsx
+++ b/apps/super-admin/src/pages/IssueInvoicePage.tsx
@@ -8,14 +8,17 @@ import { ApiError, isAbortError } from '../lib/api'
import { getBusiness, type BusinessDetail } from '../services/businessService'
import {
createBusinessInvoice,
+ getBusinessInvoice,
listInvoiceItemTemplates,
listInvoiceTemplates,
+ updateBusinessInvoice,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
+ draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
@@ -36,7 +39,8 @@ function effectivePrice(price: number, discountedPrice: number | null | undefine
}
export function IssueInvoicePage() {
- const { businessId = '' } = useParams()
+ const { businessId = '', invoiceId = '' } = useParams()
+ const isEdit = Boolean(invoiceId)
const navigate = useNavigate()
const { showToast } = useToast()
@@ -45,6 +49,7 @@ export function IssueInvoicePage() {
const [invoiceTemplates, setInvoiceTemplates] = useState ([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
+ const [locked, setLocked] = useState(false)
const [sourceTemplateId, setSourceTemplateId] = useState('')
const [invoiceTemplateId, setInvoiceTemplateId] = useState()
@@ -77,6 +82,7 @@ export function IssueInvoicePage() {
async function load() {
setLoading(true)
setError('')
+ setLocked(false)
try {
const [biz, items, templates] = await Promise.all([
getBusiness(businessId, controller.signal),
@@ -86,6 +92,24 @@ export function IssueInvoicePage() {
setBusiness(biz)
setItemTemplates(items.items.filter((t) => t.isActive))
setInvoiceTemplates(templates.items.filter((t) => t.isActive))
+
+ if (invoiceId) {
+ const invoice = await getBusinessInvoice(businessId, invoiceId, controller.signal)
+ if (invoice.status === 'approved') {
+ setLocked(true)
+ setError('This invoice is approved and can no longer be edited.')
+ }
+ const drafts = draftsFromInvoice(invoice)
+ setSourceTemplateId('')
+ setInvoiceTemplateId(invoice.invoiceTemplateId ?? undefined)
+ setInvoiceName(drafts.name)
+ setTopText(drafts.topText)
+ setNotes(drafts.notes)
+ setDraftItems(drafts.items)
+ setKeyPoints(drafts.keyPoints)
+ setAccounts(drafts.accounts)
+ setSelectedItemTemplateId('')
+ }
} catch (err) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load invoice form.')
@@ -96,7 +120,7 @@ export function IssueInvoicePage() {
void load()
return () => controller.abort()
- }, [businessId])
+ }, [businessId, invoiceId])
function resetBlankDraft() {
setSourceTemplateId('')
@@ -112,6 +136,7 @@ export function IssueInvoicePage() {
}
function applyInvoiceTemplate(templateId: string) {
+ if (locked) return
setSourceTemplateId(templateId)
if (!templateId) {
resetBlankDraft()
@@ -130,7 +155,8 @@ export function IssueInvoicePage() {
setFormError('')
}
- async function handleCreate() {
+ async function handleSubmit() {
+ if (locked) return
setFormError('')
let items
try {
@@ -142,19 +168,38 @@ export function IssueInvoicePage() {
setSubmitting(true)
try {
- await createBusinessInvoice(businessId, {
- items,
- name: invoiceName.trim() || undefined,
- topText: isEmptyRichText(topText) ? undefined : topText,
- notes: notes.trim() || undefined,
- invoiceTemplateId,
- keyPoints: buildKeyPointsPayload(keyPoints),
- accounts: buildAccountsPayload(accounts),
- })
- showToast('Invoice issued.', 'success')
+ if (isEdit) {
+ await updateBusinessInvoice(businessId, invoiceId, {
+ items,
+ name: invoiceName.trim() || null,
+ topText: isEmptyRichText(topText) ? null : topText,
+ notes: notes.trim() || null,
+ invoiceTemplateId: invoiceTemplateId ?? null,
+ keyPoints: buildKeyPointsPayload(keyPoints),
+ accounts: buildAccountsPayload(accounts),
+ })
+ showToast('Invoice updated.', 'success')
+ } else {
+ await createBusinessInvoice(businessId, {
+ items,
+ name: invoiceName.trim() || undefined,
+ topText: isEmptyRichText(topText) ? undefined : topText,
+ notes: notes.trim() || undefined,
+ invoiceTemplateId,
+ keyPoints: buildKeyPointsPayload(keyPoints),
+ accounts: buildAccountsPayload(accounts),
+ })
+ showToast('Invoice issued.', 'success')
+ }
navigate(listPath)
} catch (err) {
- setFormError(err instanceof ApiError ? err.message : 'Unable to create invoice.')
+ setFormError(
+ err instanceof ApiError
+ ? err.message
+ : isEdit
+ ? 'Unable to update invoice.'
+ : 'Unable to create invoice.',
+ )
} finally {
setSubmitting(false)
}
@@ -168,9 +213,13 @@ export function IssueInvoicePage() {
Back to invoices
- Issue invoice · {businessName}
+
+ {isEdit ? 'Edit invoice' : 'Issue invoice'} · {businessName}
+
- Select an invoice template and edit it for this business, or create a blank invoice.
+ {isEdit
+ ? 'Update invoice content for this business. Approved invoices cannot be changed.'
+ : 'Select an invoice template and edit it for this business, or create a blank invoice.'}
@@ -178,24 +227,36 @@ export function IssueInvoicePage() {
{error ? {error} : null}
{loading ? Loading… : null}
- {!loading ? (
+ {!loading && !locked ? (
-
-
-
-
+ {!isEdit ? (
+
+
+
+
+
+
+
+ setInvoiceName(e.target.value)}
+ placeholder="e.g. Website redesign package"
+ />
+
+ ) : (
-
+ )}
- {invoiceTemplates.length === 0 ? (
+ {!isEdit && invoiceTemplates.length === 0 ? (
No invoice templates yet. Manage them in{' '}
Settings → Invoice templates, or fill a blank invoice
@@ -265,14 +326,32 @@ export function IssueInvoicePage() {
void handleCreate()}
+ onClick={() => void handleSubmit()}
disabled={submitting}
>
- {submitting ? 'Issuing…' : 'Issue invoice'}
+ {submitting
+ ? isEdit
+ ? 'Saving…'
+ : 'Issuing…'
+ : isEdit
+ ? 'Save changes'
+ : 'Issue invoice'}
) : null}
+
+ {!loading && locked ? (
+
+ navigate(listPath)}
+ >
+ Back to invoices
+
+
+ ) : null}
)
}
diff --git a/apps/super-admin/src/pages/PublicInvoicePage.module.css b/apps/super-admin/src/pages/PublicInvoicePage.module.css
index 08dbd82..f43cf61 100644
--- a/apps/super-admin/src/pages/PublicInvoicePage.module.css
+++ b/apps/super-admin/src/pages/PublicInvoicePage.module.css
@@ -27,6 +27,44 @@
min-width: 180px;
}
+.toolbarActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.toolbarError {
+ flex-basis: 100%;
+ margin: 0;
+ font-size: 12px;
+ color: #b91c1c;
+}
+
+.approveBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: var(--field-height, 38px);
+ padding: 0 14px;
+ border-radius: var(--radius-sm, 12px);
+ border: 1px solid rgba(4, 120, 87, 0.35);
+ background: rgba(16, 185, 129, 0.12);
+ color: #047857;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.approveBtn:hover:not(:disabled) {
+ background: rgba(16, 185, 129, 0.2);
+}
+
+.approveBtn:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
.printBtn {
display: inline-flex;
align-items: center;
@@ -66,6 +104,26 @@
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
}
+.headerTop {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.approvedBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgba(16, 185, 129, 0.14);
+ color: #047857;
+ font-size: 12px;
+ font-weight: 650;
+}
+
.title {
margin: 0 0 4px;
font-size: 22px;
diff --git a/apps/super-admin/src/pages/UsersPage.tsx b/apps/super-admin/src/pages/UsersPage.tsx
index b0ab7f2..6b6a06e 100644
--- a/apps/super-admin/src/pages/UsersPage.tsx
+++ b/apps/super-admin/src/pages/UsersPage.tsx
@@ -23,6 +23,7 @@ import {
updateUserRole,
} from '../services/userService'
import { useToast } from '../context/ToastContext'
+import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './UsersPage.module.css'
@@ -35,14 +36,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(user: UserListItem) {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return name || '—'
@@ -80,17 +73,20 @@ export function UsersPage() {
const [editEmail, setEditEmail] = useState('')
const [editCell, setEditCell] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
+ const [editError, setEditError] = useState('')
const [resetOpen, setResetOpen] = useState(false)
const [resetUser, setResetUser] = useState(null)
const [resetPassword, setResetPassword] = useState('')
const [resetConfirm, setResetConfirm] = useState('')
const [resetSubmitting, setResetSubmitting] = useState(false)
+ const [resetError, setResetError] = useState('')
const [messageOpen, setMessageOpen] = useState(false)
const [messageUser, setMessageUser] = useState(null)
const [messageText, setMessageText] = useState('')
const [messageSubmitting, setMessageSubmitting] = useState(false)
+ const [messageError, setMessageError] = useState('')
const [removeTarget, setRemoveTarget] = useState(null)
@@ -99,6 +95,7 @@ export function UsersPage() {
const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('')
const [roleSubmitting, setRoleSubmitting] = useState(false)
+ const [roleError, setRoleError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createFirstName, setCreateFirstName] = useState('')
@@ -107,6 +104,7 @@ export function UsersPage() {
const [createPassword, setCreatePassword] = useState('')
const [createEmail, setCreateEmail] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
+ const [createError, setCreateError] = useState('')
const [listVersion, setListVersion] = useState(0)
const businessFilter = useMemo(() => {
@@ -182,8 +180,6 @@ export function UsersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
- const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
-
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -222,6 +218,7 @@ export function UsersPage() {
setCreateCell('')
setCreatePassword('')
setCreateEmail('')
+ setCreateError('')
}
function openCreate() {
@@ -235,7 +232,7 @@ export function UsersPage() {
if (!cellNumber) return
setCreateSubmitting(true)
- setError('')
+ setCreateError('')
try {
await createUser({
businessId: businessFilter.businessId,
@@ -251,7 +248,7 @@ export function UsersPage() {
setPage(1)
setListVersion((v) => v + 1)
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to add user.')
+ setCreateError(err instanceof ApiError ? err.message : 'Unable to add user.')
} finally {
setCreateSubmitting(false)
}
@@ -266,6 +263,7 @@ export function UsersPage() {
setRoleUser(user)
setSelectedRoleSlug(user.roleSlug ?? '')
setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '')
+ setRoleError('')
setRoleOpen(true)
}
@@ -280,7 +278,7 @@ export function UsersPage() {
async function submitRoleChange() {
if (!roleUser || !selectedRoleSlug || !canSaveRole) return
setRoleSubmitting(true)
- setError('')
+ setRoleError('')
try {
await updateUserRole(roleUser.id, selectedRoleSlug)
@@ -340,7 +338,7 @@ export function UsersPage() {
setRoleUser(null)
setSelectedTeamRoleSlug('')
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to update role.')
+ setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
} finally {
setRoleSubmitting(false)
}
@@ -352,13 +350,14 @@ export function UsersPage() {
setEditLastName(user.lastName ?? '')
setEditEmail('')
setEditCell(user.cellNumber)
+ setEditError('')
setEditOpen(true)
}
async function submitEdit() {
if (!editUser) return
setEditSubmitting(true)
- setError('')
+ setEditError('')
try {
await updateUser(editUser.id, {
firstName: editFirstName.trim(),
@@ -386,7 +385,7 @@ export function UsersPage() {
}
})
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to update user.')
+ setEditError(err instanceof ApiError ? err.message : 'Unable to update user.')
} finally {
setEditSubmitting(false)
}
@@ -396,24 +395,25 @@ export function UsersPage() {
setResetUser(user)
setResetPassword('')
setResetConfirm('')
+ setResetError('')
setResetOpen(true)
}
async function submitResetPassword() {
if (!resetUser) return
if (resetPassword !== resetConfirm) {
- setError('Passwords do not match.')
+ setResetError('Passwords do not match.')
return
}
setResetSubmitting(true)
- setError('')
+ setResetError('')
try {
await adminResetUserPassword(resetUser.id, resetPassword)
setResetOpen(false)
setResetUser(null)
showToast(`Password reset for "${displayName(resetUser)}".`, 'success')
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to reset password.')
+ setResetError(err instanceof ApiError ? err.message : 'Unable to reset password.')
} finally {
setResetSubmitting(false)
}
@@ -422,20 +422,21 @@ export function UsersPage() {
function openSendMessage(user: UserListItem) {
setMessageUser(user)
setMessageText('')
+ setMessageError('')
setMessageOpen(true)
}
async function submitSendMessage() {
if (!messageUser) return
setMessageSubmitting(true)
- setError('')
+ setMessageError('')
try {
const result = await sendUserMessage(messageUser.id, messageText.trim())
setMessageOpen(false)
setMessageUser(null)
showToast(result.message, result.enabled ? 'success' : 'info')
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to send message.')
+ setMessageError(err instanceof ApiError ? err.message : 'Unable to send message.')
} finally {
setMessageSubmitting(false)
}
@@ -693,37 +694,15 @@ export function UsersPage() {
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+
@@ -734,8 +713,14 @@ export function UsersPage() {
onClose={() => {
setEditOpen(false)
setEditUser(null)
+ setEditError('')
}}
>
+ {editError ? (
+
+ {editError}
+
+ ) : null}
@@ -808,8 +793,14 @@ export function UsersPage() {
onClose={() => {
setResetOpen(false)
setResetUser(null)
+ setResetError('')
}}
>
+ {resetError ? (
+
+ {resetError}
+
+ ) : null}
Set a new password for {resetUser ? displayName(resetUser) : 'user'}.
@@ -863,8 +854,14 @@ export function UsersPage() {
onClose={() => {
setMessageOpen(false)
setMessageUser(null)
+ setMessageError('')
}}
>
+ {messageError ? (
+
+ {messageError}
+
+ ) : null}
Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.
@@ -906,8 +903,14 @@ export function UsersPage() {
setRoleOpen(false)
setRoleUser(null)
setSelectedTeamRoleSlug('')
+ setRoleError('')
}}
>
+ {roleError ? (
+
+ {roleError}
+
+ ) : null}
Select a role for {roleUser ? displayName(roleUser) : 'user'}.
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''}
@@ -1021,6 +1024,11 @@ export function UsersPage() {
resetCreateForm()
}}
>
+ {createError ? (
+
+ {createError}
+
+ ) : null}
Creates a new account or links an existing user as a customer of this business.
Password is required only for new accounts.
diff --git a/apps/super-admin/src/pages/WebsitesPage.tsx b/apps/super-admin/src/pages/WebsitesPage.tsx
index 9819c4f..fd70cba 100644
--- a/apps/super-admin/src/pages/WebsitesPage.tsx
+++ b/apps/super-admin/src/pages/WebsitesPage.tsx
@@ -26,20 +26,13 @@ import {
updateDomain,
} from '../services/domainService'
import { useToast } from '../context/ToastContext'
+import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './WebsitesPage.module.css'
const PAGE_SIZE = 10
-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 daysUntilExpiry(expiresAt: string | null) {
if (!expiresAt) return null
const diff = new Date(expiresAt).getTime() - Date.now()
@@ -103,6 +96,7 @@ export function WebsitesPage() {
const [editHost, setEditHost] = useState('')
const [editExpiresAt, setEditExpiresAt] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
+ const [editError, setEditError] = useState('')
const [removeTarget, setRemoveTarget] = useState(null)
const [togglingActiveId, setTogglingActiveId] = useState(null)
@@ -142,8 +136,6 @@ export function WebsitesPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
- const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
-
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -169,13 +161,14 @@ export function WebsitesPage() {
setEditDomain(domain)
setEditHost(domain.host)
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
+ setEditError('')
setEditOpen(true)
}
async function submitEdit() {
if (!editDomain) return
setEditSubmitting(true)
- setError('')
+ setEditError('')
try {
await updateDomain(editDomain.id, {
host: editHost.trim(),
@@ -200,7 +193,7 @@ export function WebsitesPage() {
}
})
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to update domain.')
+ setEditError(err instanceof ApiError ? err.message : 'Unable to update domain.')
} finally {
setEditSubmitting(false)
}
@@ -575,37 +568,15 @@ export function WebsitesPage() {
- Page {page} / {totalPages}
-
-
- setPage((p) => Math.max(1, p - 1))}
- disabled={page <= 1 || loading}
- >
- Prev
-
- {pageNumbers.map((n) => (
- setPage(n)}
- disabled={loading}
- >
- {n}
-
- ))}
- setPage((p) => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages || loading}
- >
- Next
-
+ Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+
@@ -616,8 +587,14 @@ export function WebsitesPage() {
onClose={() => {
setEditOpen(false)
setEditDomain(null)
+ setEditError('')
}}
>
+ {editError ? (
+
+ {editError}
+
+ ) : null}
(`/businesses/${businessId}/migrate-from-old`, {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
+
+export async function purgeBusinessData(
+ businessId: string,
+ payload: PurgeBusinessDataPayload,
+) {
+ return apiRequest (`/businesses/${businessId}/purge-data`, {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
+
export interface BusinessDetail {
id: string
name: string
nameFa: string | null
slug: string
isActive: boolean
+ oldBusinessId: string | null
}
export async function getBusiness(businessId: string, signal?: AbortSignal) {
diff --git a/apps/super-admin/src/services/invoiceService.ts b/apps/super-admin/src/services/invoiceService.ts
index a595cef..8c5f2a6 100644
--- a/apps/super-admin/src/services/invoiceService.ts
+++ b/apps/super-admin/src/services/invoiceService.ts
@@ -12,6 +12,7 @@ import type {
InvoicesListResponse,
PublicInvoice,
UpdateInvoiceItemTemplatePayload,
+ UpdateInvoicePayload,
UpdateInvoiceTemplatePayload,
} from '../types/invoice'
@@ -122,6 +123,18 @@ export function createBusinessInvoice(businessId: string, payload: CreateInvoice
})
}
+export function updateBusinessInvoice(
+ businessId: string,
+ invoiceId: string,
+ payload: UpdateInvoicePayload,
+) {
+ return apiRequest(`/businesses/${businessId}/invoices/${invoiceId}`, {
+ method: 'PUT',
+ auth: true,
+ body: payload,
+ })
+}
+
export function updateBusinessInvoiceStatus(
businessId: string,
invoiceId: string,
@@ -148,3 +161,11 @@ export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
signal,
})
}
+
+/** Public approve (no auth). issued → approved. */
+export function approvePublicInvoice(publicId: string) {
+ return apiRequest(`/public/invoices/${publicId}/approve`, {
+ method: 'POST',
+ auth: false,
+ })
+}
diff --git a/apps/super-admin/src/types/business.ts b/apps/super-admin/src/types/business.ts
index 7c3892c..393d05e 100644
--- a/apps/super-admin/src/types/business.ts
+++ b/apps/super-admin/src/types/business.ts
@@ -21,6 +21,67 @@ export interface BusinessListItem {
ownerCellNumber: string | null
isActive: boolean
primaryColor: BusinessPrimaryColorId
+ oldBusinessId: string | null
+}
+
+export type MigrateFromOldEntity =
+ | 'product_categories'
+ | 'product'
+ | 'customer_categories'
+ | 'customer'
+ | 'blog_categories'
+ | 'blog'
+ | 'portfolio_categories'
+ | 'portfolio'
+
+export type PurgeBusinessDataEntity = MigrateFromOldEntity
+
+export type MigrateEntityResult =
+ | {
+ created: number
+ skipped: number
+ total: number
+ imagesCopied?: number
+ imagesResized?: number
+ imagesFailed?: number
+ titlesUpdated?: number
+ skippedInvalidCell?: number
+ skippedAlreadyLinked?: number
+ skippedCreateFailed?: number
+ }
+ | { status: 'not_implemented' }
+
+export type PurgeEntityResult =
+ | {
+ deleted: number
+ imagesDeleted?: number
+ }
+ | { status: 'not_implemented' }
+
+export interface MigrateFromOldPayload {
+ oldBusinessId: number
+ entities: MigrateFromOldEntity[]
+}
+
+export interface MigrateFromOldResponse {
+ businessId: string
+ oldBusinessId: string
+ entities: MigrateFromOldEntity[]
+ status: 'ok' | 'partial' | 'linked'
+ message: string
+ results?: Partial>
+}
+
+export interface PurgeBusinessDataPayload {
+ entities: PurgeBusinessDataEntity[]
+}
+
+export interface PurgeBusinessDataResponse {
+ businessId: string
+ entities: PurgeBusinessDataEntity[]
+ status: 'ok' | 'partial' | 'noop'
+ message: string
+ results?: Partial>
}
export interface CreateBusinessPayload {
diff --git a/apps/super-admin/src/types/invoice.ts b/apps/super-admin/src/types/invoice.ts
index d85a919..d7c29a5 100644
--- a/apps/super-admin/src/types/invoice.ts
+++ b/apps/super-admin/src/types/invoice.ts
@@ -1,4 +1,4 @@
-export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'cancelled'
+export type InvoiceStatus = 'draft' | 'issued' | 'approved' | 'paid' | 'cancelled'
export interface InvoiceItemTemplate {
id: string
@@ -125,6 +125,16 @@ export interface CreateInvoicePayload {
status?: InvoiceStatus
}
+export type UpdateInvoicePayload = {
+ items: InvoiceItemInput[]
+ name?: string | null
+ topText?: string | null
+ notes?: string | null
+ invoiceTemplateId?: string | null
+ keyPoints?: InvoiceKeyPointInput[]
+ accounts?: InvoiceAccountInput[]
+}
+
export interface InvoiceTemplateItem {
id: string
itemTemplateId: string | null
diff --git a/apps/super-admin/src/utils/invoiceDraft.ts b/apps/super-admin/src/utils/invoiceDraft.ts
index 8b8e3e6..5ac6875 100644
--- a/apps/super-admin/src/utils/invoiceDraft.ts
+++ b/apps/super-admin/src/utils/invoiceDraft.ts
@@ -112,6 +112,71 @@ export function draftsFromInvoiceTemplate(template: InvoiceTemplate): {
}
}
+export function draftsFromInvoice(invoice: {
+ name: string | null
+ topText: string | null
+ notes?: string | null
+ items?: Array<{
+ templateId: string | null
+ title: string
+ duration: string | null
+ worktime: string | null
+ description: string | null
+ price: number
+ discountedPrice: number | null
+ }>
+ keyPoints?: Array<{ text: string }>
+ accounts?: Array<{
+ bankName: string
+ accountHolderName: string | null
+ cardNumber: string | null
+ iban: string | null
+ }>
+}): {
+ name: string
+ topText: string
+ notes: string
+ items: DraftLineItem[]
+ keyPoints: DraftKeyPoint[]
+ accounts: DraftAccount[]
+} {
+ return {
+ name: invoice.name ?? '',
+ topText: invoice.topText ?? '',
+ notes: invoice.notes ?? '',
+ items:
+ (invoice.items?.length ?? 0) > 0
+ ? invoice.items!.map((item) => ({
+ key: newKey(),
+ itemTemplateId: item.templateId ?? undefined,
+ title: item.title,
+ duration: item.duration ?? '',
+ worktime: item.worktime ?? '',
+ description: item.description ?? '',
+ price: formatIrtInput(String(Math.round(item.price))),
+ discountedPrice:
+ item.discountedPrice === null || item.discountedPrice === undefined
+ ? ''
+ : formatIrtInput(String(Math.round(item.discountedPrice))),
+ }))
+ : [emptyDraftItem()],
+ keyPoints:
+ (invoice.keyPoints?.length ?? 0) > 0
+ ? invoice.keyPoints!.map((kp) => ({ key: newKey(), text: kp.text }))
+ : [],
+ accounts:
+ (invoice.accounts?.length ?? 0) > 0
+ ? invoice.accounts!.map((acc) => ({
+ key: newKey(),
+ bankName: acc.bankName,
+ accountHolderName: acc.accountHolderName ?? '',
+ cardNumber: acc.cardNumber ?? '',
+ iban: acc.iban ?? '',
+ }))
+ : [],
+ }
+}
+
export function buildLineItemsPayload(items: DraftLineItem[]): InvoiceItemInput[] {
return items.map((item) => {
const title = item.title.trim()
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index 81f0067..144cbe2 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -59,7 +59,7 @@ When migrating an app to shared packages, prefer importing from `@meshkee/dashbo
### Backend
- NestJS, Prisma, PostgreSQL, Redis
-- S3-compatible storage (Parmin) for media
+- S3-compatible storage (Parspack) for media
- API prefix: `/api/v1`
- BigInt IDs serialized as strings in JSON
@@ -138,7 +138,7 @@ Add to `/etc/hosts` (one line per tenant):
|------|------|
| `/login` | Login |
| `/` | Home |
-| `/businesses` | Businesses list |
+| `/businesses` | Businesses list (migrate-from-old + delete-data for portfolios/blogs(+news)/customers + categories; single-select) |
| `/businesses/:businessId/invoices` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
| `/users` | Users |
@@ -345,7 +345,9 @@ Two template layers + issued invoices:
**Invoice fields:** optional `name`, `topText`, `notes`, `invoiceTemplateId`, `status`, `publicUrl`, nested `items`, `keyPoints`, `accounts` (bank name, account holder, card, IBAN).
**Public invoice viewer (platform):**
-- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, “Issued by” Meshkee footer
+- Route: super-admin SPA `/invoices/:id` (`PublicInvoicePage`) — glass layout, print-to-PDF, Approve (`issued` → `approved`), “Issued by” Meshkee footer
+- Status `approved`: set from public show page; content becomes immutable for admins
+- Edit: list pencil → `/businesses/:businessId/invoices/:invoiceId/edit` (hidden when approved)
- Links use opaque **12-digit `publicId`** (not sequential PK) — `GET /public/invoices/:publicId`
- Local/dev link: current Vite origin (e.g. `https://meshkee.app:5174/invoices/{publicId}`)
- Production link domain: `VITE_INVOICE_PUBLIC_DOMAIN` / `INVOICE_PUBLIC_DOMAIN` (default `meshkee.com`) — optional full origin override via `*_PUBLIC_BASE_URL`
diff --git a/packages/dashboard-ui/src/components/Pagination.module.css b/packages/dashboard-ui/src/components/Pagination.module.css
index f9d5ee0..c48c905 100644
--- a/packages/dashboard-ui/src/components/Pagination.module.css
+++ b/packages/dashboard-ui/src/components/Pagination.module.css
@@ -7,6 +7,12 @@
padding-top: 24px;
}
+.paginationInline {
+ margin-top: 0;
+ padding-top: 0;
+ justify-content: flex-end;
+}
+
.navBtn {
width: 38px;
height: 38px;
@@ -49,11 +55,16 @@
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
-.pageBtn:hover {
+.pageBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
+.pageBtn:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
.pageBtn.active {
background: var(--primary);
border-color: var(--primary);
diff --git a/packages/dashboard-ui/src/components/Pagination.tsx b/packages/dashboard-ui/src/components/Pagination.tsx
index ad341df..86f01b6 100644
--- a/packages/dashboard-ui/src/components/Pagination.tsx
+++ b/packages/dashboard-ui/src/components/Pagination.tsx
@@ -1,11 +1,31 @@
-import { ChevronLeft, ChevronRight } from 'lucide-react'
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
import styles from './Pagination.module.css'
-interface PaginationProps {
+export interface PaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
ariaLabel?: string
+ /** Pages on each side of current. Default 3 → e.g. 1 2 3 [4] 5 6 7 */
+ siblingCount?: number
+ disabled?: boolean
+ /** `inline` for table footers (no top margin). */
+ variant?: 'default' | 'inline'
+ className?: string
+}
+
+function buildPageWindow(
+ currentPage: number,
+ totalPages: number,
+ siblingCount: number,
+): number[] {
+ const start = Math.max(1, currentPage - siblingCount)
+ const end = Math.min(totalPages, currentPage + siblingCount)
+ const pages: number[] = []
+ for (let page = start; page <= end; page += 1) {
+ pages.push(page)
+ }
+ return pages
}
export function Pagination({
@@ -13,18 +33,39 @@ export function Pagination({
totalPages,
onPageChange,
ariaLabel = 'Pagination',
+ siblingCount = 3,
+ disabled = false,
+ variant = 'default',
+ className,
}: PaginationProps) {
if (totalPages <= 1) return null
- const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
+ const pages = buildPageWindow(currentPage, totalPages, siblingCount)
+ const rootClass = [
+ styles.pagination,
+ variant === 'inline' ? styles.paginationInline : '',
+ className ?? '',
+ ]
+ .filter(Boolean)
+ .join(' ')
return (
-
+ ,
+ document.body,
)
}
diff --git a/packages/dashboard-ui/src/index.ts b/packages/dashboard-ui/src/index.ts
index 5faebdb..fb19abd 100644
--- a/packages/dashboard-ui/src/index.ts
+++ b/packages/dashboard-ui/src/index.ts
@@ -10,6 +10,7 @@ export {
} from './components/AddressListEditor'
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs'
export { Pagination } from './components/Pagination'
+export type { PaginationProps } from './components/Pagination'
export { SectionCard } from './components/SectionCard'
export { RouteLoader } from './components/RouteLoader'
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'
|