@@ -477,17 +518,9 @@ export function OrdersPage() {
),
)}
onClick={() => setStepOrder(order)}
- aria-label={`Change step: ${stepLabel(
- processSteps,
- order.processStepId ?? processSteps[0]?.id ?? 'processing',
- order.processStepLabel,
- )}`}
+ aria-label={t('orders.changeStep', { step: currentStepLabel })}
>
- {stepLabel(
- processSteps,
- order.processStepId ?? processSteps[0]?.id ?? 'processing',
- order.processStepLabel,
- )}
+ {currentStepLabel}
@@ -497,32 +530,32 @@ export function OrdersPage() {
-
+
setViewOrder(order)}
- aria-label="View items"
+ aria-label={t('orders.viewItems')}
>
-
+
setTransactionsOrder(order)}
- aria-label="Transaction details"
+ aria-label={t('orders.transactions')}
>
-
+
setRemoveTarget(order)}
- aria-label="Remove order"
+ aria-label={t('orders.remove')}
disabled={removing}
>
@@ -538,7 +571,12 @@ export function OrdersPage() {
- Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+ {t('orders.pageMeta', {
+ page,
+ totalPages,
+ pageSize: PAGE_SIZE,
+ total: data?.total ?? 0,
+ })}
setRemoveTarget(null)}
diff --git a/apps/business/src/pages/PortfolioCategoriesPage.tsx b/apps/business/src/pages/PortfolioCategoriesPage.tsx
index 621c7c1..5c5fc4b 100644
--- a/apps/business/src/pages/PortfolioCategoriesPage.tsx
+++ b/apps/business/src/pages/PortfolioCategoriesPage.tsx
@@ -6,6 +6,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api'
+import { useT } from '../i18n/useT'
import {
createPortfolioCategory,
deletePortfolioCategory,
@@ -18,11 +19,12 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css'
export function PortfolioCategoriesPage() {
+ const t = useT()
const [categories, setCategories] = useState([])
const [expandedIds, setExpandedIds] = useState>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
- const [modalTitle, setModalTitle] = useState('Add Category')
+ const [modalTitle, setModalTitle] = useState(() => t('categories.add'))
const [editingCategory, setEditingCategory] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -46,17 +48,17 @@ export function PortfolioCategoriesPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load categories.')
+ setError(t('portfolio.categories.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
- function openCreateModal(parentId = '', title = 'Add Category') {
+ function openCreateModal(parentId = '', title?: string) {
setEditingCategory(null)
setDefaultParentId(parentId)
- setModalTitle(title)
+ setModalTitle(title ?? t('categories.add'))
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
@@ -66,7 +68,7 @@ export function PortfolioCategoriesPage() {
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
- setModalTitle('Edit Category')
+ setModalTitle(t('categories.edit'))
setModalOpen(true)
}
@@ -112,7 +114,7 @@ export function PortfolioCategoriesPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to save category.')
+ setError(t('portfolio.categories.errorSave'))
}
} finally {
setIsSubmitting(false)
@@ -139,7 +141,7 @@ export function PortfolioCategoriesPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to delete category.')
+ setError(t('portfolio.categories.errorDelete'))
}
} finally {
setIsSubmitting(false)
@@ -157,10 +159,8 @@ export function PortfolioCategoriesPage() {
/>
-
Portfolio Categories
-
- Organize your portfolio items into categories and subcategories.
-
+
{t('portfolio.categories.title')}
+
{t('portfolio.categories.subtitle')}
@@ -172,15 +172,15 @@ export function PortfolioCategoriesPage() {
{isLoading ? (
-
Loading categories...
+
{t('categories.loading')}
) : categories.length === 0 ? (
-
No categories yet. Click + to add one.
+
{t('portfolio.categories.empty')}
) : (
openCreateModal(id, 'Add Sub Category')}
+ onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
@@ -211,10 +211,10 @@ export function PortfolioCategoriesPage() {
openCreateModal()}
- aria-label="Add category"
+ aria-label={t('categories.add')}
>
diff --git a/apps/business/src/pages/PortfolioDetailsPage.tsx b/apps/business/src/pages/PortfolioDetailsPage.tsx
index a4be60b..0f03485 100644
--- a/apps/business/src/pages/PortfolioDetailsPage.tsx
+++ b/apps/business/src/pages/PortfolioDetailsPage.tsx
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { CalendarDays } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox'
import { PortfolioCommentsSection } from '../components/PortfolioCommentsSection'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { formatPortfolioDate, getPortfolioDetail } from '../services/portfolioService'
import type { PortfolioDetail } from '../types/portfolio'
@@ -11,6 +13,8 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './PortfolioDetailsPage.module.css'
export function PortfolioDetailsPage() {
+ const t = useT()
+ const { locale } = useLocale()
const { id } = useParams()
const [portfolio, setPortfolio] = useState(null)
const [isLoading, setIsLoading] = useState(true)
@@ -35,7 +39,7 @@ export function PortfolioDetailsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load portfolio.')
+ setError(t('portfolio.details.errorLoad'))
}
setPortfolio(null)
} finally {
@@ -60,7 +64,7 @@ export function PortfolioDetailsPage() {
if (isLoading) {
return (
- Loading portfolio...
+ {t('portfolio.details.loading')}
)
}
@@ -68,15 +72,18 @@ export function PortfolioDetailsPage() {
if (error || !portfolio || !id) {
return (
- {error || 'Portfolio not found.'}
+ {error || t('portfolio.details.notFound')}
- Back to My Portfolios
+ {t('portfolio.details.back')}
)
}
- const displayDate = formatPortfolioDate(portfolio.publishedAt ?? portfolio.createdAt)
+ const displayDate = formatPortfolioDate(
+ portfolio.publishedAt ?? portfolio.createdAt,
+ locale === 'fa' ? 'fa' : 'en',
+ )
return (
<>
@@ -145,16 +152,16 @@ export function PortfolioDetailsPage() {
dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }}
/>
) : (
- No content yet.
+ {t('portfolio.details.noContent')}
)}
{galleryImages.length > 0 && (
-
+
-
Gallery
+
{t('portfolio.details.gallery')}
{galleryImages.map((src, index) => (
openLightbox(index)}
- aria-label={`View gallery image ${index + 1}`}
+ aria-label={t('portfolio.details.galleryImage', { index: index + 1 })}
>
diff --git a/apps/business/src/pages/PortfolioListPage.tsx b/apps/business/src/pages/PortfolioListPage.tsx
index cd53d36..1d9b552 100644
--- a/apps/business/src/pages/PortfolioListPage.tsx
+++ b/apps/business/src/pages/PortfolioListPage.tsx
@@ -7,6 +7,7 @@ import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
PORTFOLIOS_PER_PAGE,
@@ -21,6 +22,7 @@ import styles from './BlogPage.module.css'
export function PortfolioListPage() {
const navigate = useNavigate()
const { showToast } = useToast()
+ const t = useT()
const [portfolios, setPortfolios] = useState
([])
const [totalPortfolios, setTotalPortfolios] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
@@ -58,7 +60,7 @@ export function PortfolioListPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load portfolios.')
+ setError(t('portfolio.list.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -94,7 +96,6 @@ export function PortfolioListPage() {
try {
if (index === 0) {
- // First on this page, but not global first — bump above the current band.
await updatePortfolio(current.id, { sortOrder: current.sortOrder - 1 })
} else {
const previous = portfolios[index - 1]
@@ -107,13 +108,13 @@ export function PortfolioListPage() {
])
}
}
- showToast('Portfolio moved up.', 'success')
+ showToast(t('portfolio.list.toast.movedUp'), 'success')
await loadPortfolios(currentPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to move portfolio.')
+ setError(t('portfolio.list.errorMove'))
}
} finally {
setMovingUpId(null)
@@ -128,7 +129,7 @@ export function PortfolioListPage() {
try {
await deletePortfolio(deleteTarget.id)
- showToast('Portfolio removed.', 'success')
+ showToast(t('portfolio.list.toast.removed'), 'success')
const nextTotal = totalPortfolios - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
@@ -139,7 +140,7 @@ export function PortfolioListPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to delete portfolio.')
+ setError(t('portfolio.list.errorDelete'))
}
} finally {
setIsDeleting(false)
@@ -171,9 +172,9 @@ export function PortfolioListPage() {
-
My Portfolios
+
{t('title.myPortfolios')}
- {totalPortfolios} items · View, edit and manage your portfolio content.
+ {t('portfolio.list.subtitle', { count: totalPortfolios })}
@@ -185,9 +186,9 @@ export function PortfolioListPage() {
)}
{isLoading ? (
- Loading portfolios...
+ {t('portfolio.list.loading')}
) : portfolios.length === 0 ? (
- No portfolio items found.
+ {t('portfolio.list.empty')}
) : (
<>
@@ -218,17 +219,17 @@ export function PortfolioListPage() {
type="button"
className={styles.addFab}
onClick={() => navigate('/portfolios/new')}
- aria-label="Add new portfolio"
+ aria-label={t('portfolio.list.addNew')}
>
(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
@@ -41,7 +43,7 @@ export function PortfolioSettingsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load settings.')
+ setError(t('productSettings.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -57,12 +59,12 @@ export function PortfolioSettingsPage() {
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
- showToast('Settings saved.', 'success')
+ showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to save settings.')
+ setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
@@ -81,10 +83,8 @@ export function PortfolioSettingsPage() {
-
Portfolio settings
-
- Configure how portfolio comments are moderated.
-
+
{t('portfolio.settings.title')}
+
{t('portfolio.settings.subtitle')}
@@ -95,27 +95,24 @@ export function PortfolioSettingsPage() {
)}
- Moderation
+ {t('productSettings.moderation')}
{isLoading ? (
- Loading settings...
+ {t('productSettings.loading')}
) : (
- Auto-approve comments
+ {t('portfolio.settings.commentsAuto')}
-
- New portfolio comments are published immediately when submitted. You can still
- reject them later if needed.
-
+
{t('portfolio.settings.commentsAutoDesc')}
diff --git a/apps/business/src/pages/PortfoliosPage.tsx b/apps/business/src/pages/PortfoliosPage.tsx
index 578b6cf..5852d93 100644
--- a/apps/business/src/pages/PortfoliosPage.tsx
+++ b/apps/business/src/pages/PortfoliosPage.tsx
@@ -1,40 +1,50 @@
import { Briefcase, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
+import { useT } from '../i18n/useT'
+import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
-const portfolioSections = [
+const portfolioSections: {
+ icon: typeof Briefcase
+ titleKey: BusinessMessageKey
+ descKey: BusinessMessageKey
+ linkKey: BusinessMessageKey
+ href: string
+}[] = [
{
icon: Briefcase,
- title: 'My Portfolios',
- description: 'View, edit and manage all your portfolio items.',
- linkText: 'View portfolios',
+ titleKey: 'nav.portfolios.list',
+ descKey: 'portfolio.card.list.desc',
+ linkKey: 'portfolio.card.list.link',
href: '/portfolios/list',
},
{
icon: PlusCircle,
- title: 'Add New Portfolio',
- description: 'Create and publish a new portfolio project.',
- linkText: 'Add portfolio',
+ titleKey: 'nav.portfolios.new',
+ descKey: 'portfolio.card.new.desc',
+ linkKey: 'portfolio.card.new.link',
href: '/portfolios/new',
},
{
icon: FolderTree,
- title: 'Portfolio Categories',
- description: 'Organize portfolio items into categories and subcategories.',
- linkText: 'View categories',
+ titleKey: 'portfolio.card.categories.title',
+ descKey: 'portfolio.card.categories.desc',
+ linkKey: 'portfolio.card.categories.link',
href: '/portfolios/categories',
},
{
icon: Settings,
- title: 'Settings',
- description: 'Configure portfolio comment moderation and display options.',
- linkText: 'View settings',
+ titleKey: 'nav.portfolios.settings',
+ descKey: 'portfolio.card.settings.desc',
+ linkKey: 'portfolio.card.settings.link',
href: '/portfolios/settings',
},
]
export function PortfoliosPage() {
+ const t = useT()
+
return (
-
Portfolios
-
- Manage your portfolio items and showcase projects.
-
+
{t('title.portfolios')}
+
{t('portfolio.page.subtitle')}
{portfolioSections.map((section) => (
-
+
))}
diff --git a/apps/business/src/pages/ProductSettingsPage.tsx b/apps/business/src/pages/ProductSettingsPage.tsx
index 66c55d6..536c1f3 100644
--- a/apps/business/src/pages/ProductSettingsPage.tsx
+++ b/apps/business/src/pages/ProductSettingsPage.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getSettings,
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
}
export function ProductSettingsPage() {
+ const t = useT()
const { showToast } = useToast()
const [settings, setSettings] = useState
(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
@@ -41,7 +43,7 @@ export function ProductSettingsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load settings.')
+ setError(t('productSettings.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -57,12 +59,12 @@ export function ProductSettingsPage() {
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
- showToast('Settings saved.', 'success')
+ showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to save settings.')
+ setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
@@ -78,12 +80,12 @@ export function ProductSettingsPage() {
expertReviews: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
- showToast('Settings saved.', 'success')
+ showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to save settings.')
+ setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
@@ -102,10 +104,8 @@ export function ProductSettingsPage() {
-
Product settings
-
- Configure how comments and expert reviews are moderated.
-
+
{t('productSettings.title')}
+
{t('productSettings.subtitle')}
@@ -116,27 +116,24 @@ export function ProductSettingsPage() {
)}
- Moderation
+ {t('productSettings.moderation')}
{isLoading ? (
- Loading settings...
+ {t('productSettings.loading')}
) : (
- Auto-approve comments
+ {t('productSettings.commentsAuto')}
-
- New comments are published immediately when submitted. You can still reject
- them later if needed.
-
+
{t('productSettings.commentsAutoDesc')}
@@ -144,18 +141,15 @@ export function ProductSettingsPage() {
- Auto-approve expert reviews
+ {t('productSettings.reviewsAuto')}
-
- New expert reviews are published immediately when submitted. You can still
- reject them later if needed.
-
+
{t('productSettings.reviewsAutoDesc')}
void handleExpertReviewsAutoApprove(checked)}
/>
diff --git a/apps/business/src/pages/ShoppingCardsPage.tsx b/apps/business/src/pages/ShoppingCardsPage.tsx
index e5bd80b..4a4b4de 100644
--- a/apps/business/src/pages/ShoppingCardsPage.tsx
+++ b/apps/business/src/pages/ShoppingCardsPage.tsx
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
+import { useLocale } from '@meshkee/dashboard-ui'
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 { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
@@ -40,12 +42,13 @@ function displayName(card: ShoppingCard) {
return name || '—'
}
-function formatDateTime(value: string) {
+function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
+ const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return {
- date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
- time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
+ date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
+ time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
}
}
@@ -54,6 +57,8 @@ function totalItemQuantity(card: ShoppingCard) {
}
export function ShoppingCardsPage() {
+ const t = useT()
+ const { locale } = useLocale()
const navigate = useNavigate()
const { showToast } = useToast()
const [data, setData] = useState
(null)
@@ -91,7 +96,7 @@ export function ShoppingCardsPage() {
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
- setError(err instanceof ApiError ? err.message : 'Unable to load shopping cards.')
+ setError(err instanceof ApiError ? err.message : t('shoppingCards.error.load'))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
@@ -109,6 +114,7 @@ export function ShoppingCardsPage() {
appliedFilters.dateTo,
appliedFilters.minTotal,
appliedFilters.maxTotal,
+ t,
])
const totalPages = useMemo(() => {
@@ -131,7 +137,7 @@ export function ShoppingCardsPage() {
const maxTotal = parseIrtInput(draftMaxCost)
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
- setError('Minimum cost cannot be greater than maximum cost.')
+ setError(t('orders.error.minMax'))
return
}
@@ -175,10 +181,10 @@ export function ShoppingCardsPage() {
items: prev.items.filter((item) => item.id !== removeTarget.id),
}
})
- showToast('Shopping card removed.', 'success')
+ showToast(t('shoppingCards.removed'), 'success')
setRemoveTarget(null)
} catch (err) {
- setError(err instanceof ApiError ? err.message : 'Unable to remove shopping card.')
+ setError(err instanceof ApiError ? err.message : t('shoppingCards.error.remove'))
} finally {
setRemoving(false)
}
@@ -196,66 +202,66 @@ export function ShoppingCardsPage() {
-
Shopping cards
-
- Saved operator carts waiting to be completed as orders.
-
+
{t('shoppingCards.title')}
+
{t('shoppingCards.subtitle')}
-
Filters
+
{t('orders.filters')}
- Customer name or number
setDraftCustomer(e.target.value)}
- placeholder="Name or phone"
+ placeholder={t('orders.filter.customer')}
+ aria-label={t('orders.filter.customer')}
autoComplete="off"
/>
- Date from
setDraftDateFrom(e.target.value)}
+ aria-label={t('orders.filter.dateFrom')}
+ title={t('orders.filter.dateFrom')}
/>
- Date to
setDraftDateTo(e.target.value)}
+ aria-label={t('orders.filter.dateTo')}
+ title={t('orders.filter.dateTo')}
/>
- Min cost (IRT)
setDraftMinCost(formatIrtInput(e.target.value))}
- placeholder="0"
+ placeholder={t('orders.filter.minCost')}
+ aria-label={t('orders.filter.minCost')}
autoComplete="off"
/>
- Max cost (IRT)
setDraftMaxCost(formatIrtInput(e.target.value))}
- placeholder="0"
+ placeholder={t('orders.filter.maxCost')}
+ aria-label={t('orders.filter.maxCost')}
autoComplete="off"
/>
@@ -266,8 +272,8 @@ export function ShoppingCardsPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
- aria-label="Search"
- title="Search"
+ aria-label={t('orders.search')}
+ title={t('orders.search')}
>
@@ -276,8 +282,8 @@ export function ShoppingCardsPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
- aria-label="Clear filters"
- title="Clear filters"
+ aria-label={t('orders.clearFilters')}
+ title={t('orders.clearFilters')}
>
@@ -288,15 +294,17 @@ export function ShoppingCardsPage() {
-
Shopping card list
+
{t('shoppingCards.listTitle')}
{data ? (
data.total > 0 ? (
- <>
- Showing {showingFrom} - {showingTo} of {data.total}
- >
+ t('orders.showing', {
+ from: showingFrom,
+ to: showingTo,
+ total: data.total,
+ })
) : (
- 'No shopping cards'
+ t('shoppingCards.none')
)
) : (
' '
@@ -306,7 +314,7 @@ export function ShoppingCardsPage() {
{error &&
{error}
}
-
+
@@ -316,18 +324,20 @@ export function ShoppingCardsPage() {
- Customer
- Items
- Total cost
- Date & time
- Actions
+ {t('orders.col.customer')}
+ {t('orders.col.items')}
+ {t('orders.col.total')}
+ {t('orders.col.date')}
+
+ {t('orders.col.actions')}
+
{loading && (
- Loading...
+ {t('shoppingCards.loading')}
)}
@@ -335,14 +345,14 @@ export function ShoppingCardsPage() {
{!loading && data?.items?.length === 0 && (
- No results found.
+ {t('shoppingCards.empty')}
)}
{!loading &&
data?.items?.map((card) => {
- const { date, time } = formatDateTime(card.createdAt)
+ const { date, time } = formatDateTime(card.createdAt, locale)
const itemQty = totalItemQuantity(card)
return (
@@ -363,22 +373,22 @@ export function ShoppingCardsPage() {
-
+
handleContinue(card)}
- aria-label="Continue shopping card"
+ aria-label={t('shoppingCards.continueAria')}
>
-
+
setRemoveTarget(card)}
- aria-label="Remove shopping card"
+ aria-label={t('shoppingCards.removeAria')}
disabled={removing}
>
@@ -394,7 +404,12 @@ export function ShoppingCardsPage() {
- Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+ {t('orders.pageMeta', {
+ page,
+ totalPages,
+ pageSize: PAGE_SIZE,
+ total: data?.total ?? 0,
+ })}
setRemoveTarget(null)}
diff --git a/apps/business/src/pages/StoreItemsPage.module.css b/apps/business/src/pages/StoreItemsPage.module.css
index 3033b09..aef67f8 100644
--- a/apps/business/src/pages/StoreItemsPage.module.css
+++ b/apps/business/src/pages/StoreItemsPage.module.css
@@ -26,12 +26,13 @@
.fabDock {
position: fixed;
- right: 32px;
+ inset-inline-end: 32px;
+ inset-inline-start: auto;
bottom: 32px;
display: flex;
align-items: center;
gap: 10px;
- z-index: 50;
+ z-index: 110;
}
.cartFabStrip {
@@ -118,7 +119,8 @@
@media (max-width: 768px) {
.fabDock {
- right: 20px;
+ inset-inline-end: 20px;
+ inset-inline-start: auto;
bottom: 20px;
gap: 8px;
}
diff --git a/apps/business/src/pages/StoreItemsPage.tsx b/apps/business/src/pages/StoreItemsPage.tsx
index 8d8d31d..eb2a420 100644
--- a/apps/business/src/pages/StoreItemsPage.tsx
+++ b/apps/business/src/pages/StoreItemsPage.tsx
@@ -31,6 +31,8 @@ import {
type StoreProductListing,
} from '../utils/storeProductGroups'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
+import { useLocale } from '@meshkee/dashboard-ui'
+import { useT } from '../i18n/useT'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './StoreItemsPage.module.css'
@@ -40,6 +42,8 @@ export function StoreItemsPage() {
}
function StoreItemsPageContent() {
+ const t = useT()
+ const { locale } = useLocale()
const { itemCount, hasItems, addVariant, loadShoppingCard } = useDraftCart()
const { showToast } = useToast()
const [items, setItems] = useState([])
@@ -139,7 +143,7 @@ function StoreItemsPageContent() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load store items.')
+ setError(t('storeItems.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -188,7 +192,7 @@ function StoreItemsPageContent() {
return
}
- showToast('Added to shopping cart.', 'success')
+ showToast(t('storeItems.addedToCart'), 'success')
}
function handleVariantPicked(variant: StoreItem) {
@@ -198,7 +202,7 @@ function StoreItemsPageContent() {
showToast(feedback, 'error')
return
}
- showToast('Added to shopping cart.', 'success')
+ showToast(t('storeItems.addedToCart'), 'success')
}
async function confirmDelete() {
@@ -215,7 +219,7 @@ function StoreItemsPageContent() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to remove store item.')
+ setError(t('storeItems.errorRemove'))
}
} finally {
setIsDeleting(false)
@@ -234,65 +238,60 @@ function StoreItemsPageContent() {
-
My Store Items
-
- Products for sale with one or more priced variants.
-
+
{t('title.storeItems')}
+
{t('storeItems.subtitle')}
-
Filters
+
{t('storeItems.filters')}
- Name
setDraftName(e.target.value)}
- placeholder="Search by product name"
+ placeholder={t('storeItems.filterNamePlaceholder')}
+ aria-label={t('storeItems.filterName')}
disabled={isLoading}
/>
- Min price (IRT)
setDraftMinPrice(formatIrtInput(e.target.value))}
- placeholder="0"
+ placeholder={t('storeItems.minPrice')}
+ aria-label={t('storeItems.minPrice')}
disabled={isLoading}
/>
- Max price (IRT)
setDraftMaxPrice(formatIrtInput(e.target.value))}
- placeholder="0"
+ placeholder={t('storeItems.maxPrice')}
+ aria-label={t('storeItems.maxPrice')}
disabled={isLoading}
/>
-
- Only discounted
-
!isLoading && setDraftOnlyDiscounted(!draftOnlyDiscounted)}
>
- Only discounted
+ {t('storeItems.onlyDiscounted')}
@@ -303,8 +302,8 @@ function StoreItemsPageContent() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={isLoading}
- aria-label="Search"
- title="Search"
+ aria-label={t('storeItems.search')}
+ title={t('storeItems.search')}
>
@@ -313,8 +312,8 @@ function StoreItemsPageContent() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={isLoading}
- aria-label="Clear filters"
- title="Clear filters"
+ aria-label={t('storeItems.clearFilters')}
+ title={t('storeItems.clearFilters')}
>
@@ -325,13 +324,11 @@ function StoreItemsPageContent() {
{error &&
{error}
}
{isLoading ? (
-
Loading store items...
+
{t('storeItems.loading')}
) : listings.length === 0 ? (
-
- No store items yet. Use the + button to add products from your catalog.
-
+
{t('storeItems.empty')}
) : filteredListings.length === 0 ? (
-
No store items match your filters.
+
{t('storeItems.emptyFiltered')}
) : (
{filteredListings.map((listing) => (
@@ -355,11 +352,11 @@ function StoreItemsPageContent() {
type="button"
className={styles.cartFabStrip}
onClick={() => setCartOpen(true)}
- aria-label={`Open shopping cart, ${itemCount} items`}
+ aria-label={t('storeItems.cartOpen', { count: itemCount })}
>
{itemCount}
- {itemCount === 1 ? 'item in cart' : 'items in cart'}
+ {itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
)}
@@ -367,7 +364,7 @@ function StoreItemsPageContent() {
type="button"
className={styles.addFab}
onClick={() => setCreateOpen(true)}
- aria-label="Add store items"
+ aria-label={t('storeItems.add')}
>
@@ -408,10 +405,13 @@ function StoreItemsPageContent() {
void confirmDelete()}
diff --git a/apps/business/src/pages/StorePage.tsx b/apps/business/src/pages/StorePage.tsx
index 84b7559..813f964 100644
--- a/apps/business/src/pages/StorePage.tsx
+++ b/apps/business/src/pages/StorePage.tsx
@@ -7,47 +7,51 @@ import {
} from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
+import { useT } from '../i18n/useT'
+import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
-const storeSections = [
+const storeSections: {
+ icon: typeof Package
+ titleKey: BusinessMessageKey
+ descKey: BusinessMessageKey
+ href: string
+}[] = [
{
icon: Package,
- title: 'My Store Items',
- description: 'View and manage all items listed in your store.',
- linkText: 'View items',
+ titleKey: 'nav.store.items',
+ descKey: 'store.card.items.desc',
href: '/store/items',
},
{
icon: ShoppingCart,
- title: 'My Orders',
- description: 'Track and manage customer orders and fulfillment.',
- linkText: 'View orders',
+ titleKey: 'nav.store.orders',
+ descKey: 'store.card.orders.desc',
href: '/store/orders',
},
{
icon: Truck,
- title: 'Shipping Fees',
- description: 'Configure shipping rates, zones and delivery options.',
- linkText: 'Manage shipping',
+ titleKey: 'nav.store.shipping',
+ descKey: 'store.card.shipping.desc',
href: '/store/shipping',
},
{
icon: CreditCard,
- title: 'Shopping Cards',
- description: 'Manage saved shopping cards and payment methods.',
- linkText: 'View cards',
+ titleKey: 'nav.store.cards',
+ descKey: 'store.card.cards.desc',
href: '/store/cards',
},
{
icon: Settings,
- title: 'Settings',
- description: 'Configure store preferences, pages and themes.',
- linkText: 'View settings',
+ titleKey: 'nav.store.settings',
+ descKey: 'store.card.settings.desc',
href: '/store/settings',
},
]
export function StorePage() {
+ const t = useT()
+
return (
-
Store
-
- Manage your store items, orders and settings.
-
+
{t('title.store')}
+
{t('store.page.subtitle')}
{storeSections.map((section) => (
-
+
))}
diff --git a/apps/business/src/pages/StoreSettingsPage.tsx b/apps/business/src/pages/StoreSettingsPage.tsx
index b29ce73..a111ad0 100644
--- a/apps/business/src/pages/StoreSettingsPage.tsx
+++ b/apps/business/src/pages/StoreSettingsPage.tsx
@@ -5,6 +5,7 @@ import { StepColorPicker } from '../components/StepColorPicker'
import { Switch } from '../components/Switch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getSettings,
@@ -53,6 +54,7 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
}
export function StoreSettingsPage() {
+ const t = useT()
const { showToast } = useToast()
const [settings, setSettings] = useState(DEFAULT_STORE_SETTINGS)
const [draftSteps, setDraftSteps] = useState(
@@ -81,7 +83,7 @@ export function StoreSettingsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load settings.')
+ setError(t('productSettings.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -95,12 +97,12 @@ export function StoreSettingsPage() {
try {
const data = await updateStoreSettings({ onlineSellEnabled: checked })
setSettings(data.settings.store)
- showToast('Settings saved.', 'success')
+ showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to save settings.')
+ setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
@@ -163,11 +165,11 @@ export function StoreSettingsPage() {
const normalized = normalizeSteps(draftSteps)
const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
if (!normalized.length) {
- setError('Add at least one order process step.')
+ setError(t('storeSettings.error.minSteps'))
return
}
if (hasEmptyLabel) {
- setError('Every order step needs an English and Farsi label.')
+ setError(t('storeSettings.error.labels'))
return
}
@@ -178,12 +180,12 @@ export function StoreSettingsPage() {
const data = await updateStoreSettings({ orderProcessSteps: normalized })
setSettings(data.settings.store)
setDraftSteps(data.settings.store.orderProcessSteps)
- showToast('Order process steps saved.', 'success')
+ showToast(t('storeSettings.stepsSaved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to save order process steps.')
+ setError(t('storeSettings.error.saveSteps'))
}
} finally {
setSavingKey(null)
@@ -213,10 +215,8 @@ export function StoreSettingsPage() {
-
Store settings
-
- Control online sales and define how orders move through fulfillment.
-
+
{t('storeSettings.title')}
+
{t('storeSettings.subtitle')}
@@ -227,27 +227,26 @@ export function StoreSettingsPage() {
)}
- Sales
+ {t('storeSettings.sales')}
{isLoading ? (
- Loading settings...
+ {t('productSettings.loading')}
) : (
- Online sell
+ {t('storeSettings.onlineSell')}
- When disabled, all sales on your website are turned off. Customers
- will not be able to place new orders online.
+ {t('storeSettings.onlineSellDesc')}
void handleOnlineSellChange(checked)}
/>
@@ -258,16 +257,13 @@ export function StoreSettingsPage() {
-
Order process
-
- Define the steps an order can move through — for example: under
- processing, ready for shipping, shipped, delivered.
-
+
{t('storeSettings.orderProcess')}
+
{t('storeSettings.orderProcessDesc')}
{isLoading ? (
- Loading settings...
+ {t('productSettings.loading')}
) : (
<>
@@ -277,14 +273,14 @@ export function StoreSettingsPage() {
updateStepColor(step.id, color)}
- ariaLabel={`Color for step ${index + 1}`}
+ ariaLabel={t('storeSettings.stepColorAria', { index: index + 1 })}
/>
updateStepLabel(step.id, e.target.value)}
@@ -293,42 +289,42 @@ export function StoreSettingsPage() {
type="text"
className={`${styles.stepInput} ${styles.stepInputFa}`}
value={step.labelFa ?? ''}
- placeholder="عنوان (فارسی)"
- aria-label={`Order step ${index + 1} Farsi label`}
+ placeholder={t('storeSettings.labelFa')}
+ aria-label={t('storeSettings.stepFaAria', { index: index + 1 })}
dir="rtl"
lang="fa"
onChange={(e) => updateStepLabelFa(step.id, e.target.value)}
/>
-
+
moveStep(step.id, -1)}
disabled={index === 0}
- aria-label="Move step up"
+ aria-label={t('storeSettings.moveUp')}
>
-
+
moveStep(step.id, 1)}
disabled={index === draftSteps.length - 1}
- aria-label="Move step down"
+ aria-label={t('storeSettings.moveDown')}
>
-
+
removeStep(step.id)}
disabled={draftSteps.length <= 1}
- aria-label="Remove step"
+ aria-label={t('storeSettings.removeStep')}
>
@@ -338,15 +334,13 @@ export function StoreSettingsPage() {
))}
{!draftSteps.length && (
-
- No order steps yet. Add the first step to define your workflow.
-
+ {t('storeSettings.emptySteps')}
)}
- Add step
+ {t('storeSettings.addStep')}
@@ -356,7 +350,9 @@ export function StoreSettingsPage() {
onClick={() => void handleSaveSteps()}
disabled={!canSaveSteps || savingKey === 'orderSteps'}
>
- {savingKey === 'orderSteps' ? 'Saving...' : 'Save steps'}
+ {savingKey === 'orderSteps'
+ ? t('storeSettings.saving')
+ : t('storeSettings.saveSteps')}
>
diff --git a/apps/business/src/pages/StoreSpecialsPage.tsx b/apps/business/src/pages/StoreSpecialsPage.tsx
index 6b45d77..2f9fecc 100644
--- a/apps/business/src/pages/StoreSpecialsPage.tsx
+++ b/apps/business/src/pages/StoreSpecialsPage.tsx
@@ -12,6 +12,7 @@ import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createStoreSpecial,
@@ -27,6 +28,7 @@ import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
export function StoreSpecialsPage() {
+ const t = useT()
const { itemCount, hasItems, addVariant } = useDraftCart()
const { showToast } = useToast()
const [specials, setSpecials] = useState([])
@@ -73,7 +75,7 @@ export function StoreSpecialsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load special categories.')
+ setError(t('website.specialItems.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -141,45 +143,49 @@ export function StoreSpecialsPage() {
)
}
- async function handleCreateSpecial(title: string) {
+ async function handleCreateSpecial(values: { key: string; title: string }) {
setIsSaving(true)
setError('')
try {
const result = await createStoreSpecial({
- title,
+ key: values.key,
+ title: values.title,
sortOrder: specials.length,
})
setSpecials((prev) => [...prev, result.special])
setCreateOpen(false)
- showToast('Special category created.', 'success')
+ showToast(t('website.specialItems.toastCreated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to create special category.')
+ setError(t('website.specialItems.errorCreate'))
}
} finally {
setIsSaving(false)
}
}
- async function handleEditSpecial(title: string) {
+ async function handleEditSpecial(values: { key: string; title: string }) {
if (!editSpecialTarget) return
setIsSaving(true)
setError('')
try {
- const result = await updateStoreSpecial(editSpecialTarget.id, { title })
+ const result = await updateStoreSpecial(editSpecialTarget.id, {
+ key: values.key,
+ title: values.title,
+ })
replaceSpecial(result.special)
setEditSpecialTarget(null)
- showToast('Special category updated.', 'success')
+ showToast(t('website.specialItems.toastUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to update special category.')
+ setError(t('website.specialItems.errorUpdate'))
}
} finally {
setIsSaving(false)
@@ -196,12 +202,12 @@ export function StoreSpecialsPage() {
await deleteStoreSpecial(deleteSpecialTarget.id)
setSpecials((prev) => prev.filter((entry) => entry.id !== deleteSpecialTarget.id))
setDeleteSpecialTarget(null)
- showToast('Special category deleted.', 'success')
+ showToast(t('website.specialItems.toastDeleted'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to delete special category.')
+ setError(t('website.specialItems.errorDelete'))
}
} finally {
setIsSaving(false)
@@ -223,12 +229,12 @@ export function StoreSpecialsPage() {
})
replaceSpecial(result.special)
setPickItemsTarget(null)
- showToast('Store items added to special category.', 'success')
+ showToast(t('website.specialItems.toastAdded'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to add store items.')
+ setError(t('website.specialItems.errorAdd'))
}
} finally {
setIsSaving(false)
@@ -253,12 +259,12 @@ export function StoreSpecialsPage() {
})
replaceSpecial(result.special)
setRemoveTarget(null)
- showToast('Removed from special category.', 'success')
+ showToast(t('website.specialItems.toastRemoved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to remove store item.')
+ setError(t('website.specialItems.errorRemove'))
}
} finally {
setIsSaving(false)
@@ -280,7 +286,7 @@ export function StoreSpecialsPage() {
return
}
- showToast('Added to shopping cart.', 'success')
+ showToast(t('storeItems.addedToCart'), 'success')
}
function handleVariantPicked(variant: StoreItem) {
@@ -290,7 +296,7 @@ export function StoreSpecialsPage() {
showToast(feedback, 'error')
return
}
- showToast('Added to shopping cart.', 'success')
+ showToast(t('storeItems.addedToCart'), 'success')
}
return (
@@ -305,22 +311,17 @@ export function StoreSpecialsPage() {
-
Special Items
-
- Curate featured store items into categories for your website carousels.
-
+
{t('title.specialItems')}
+
{t('website.specialItems.subtitle')}
{error && {error}
}
{isLoading ? (
- Loading special categories...
+ {t('website.specialItems.loading')}
) : specials.length === 0 ? (
-
- No special categories yet. Use the + button to create one, then add store items to each
- carousel.
-
+ {t('website.specialItems.empty')}
) : (
{specials.map((special) => (
@@ -349,11 +350,11 @@ export function StoreSpecialsPage() {
type="button"
className={fabStyles.cartFabStrip}
onClick={() => setCartOpen(true)}
- aria-label={`Open shopping cart, ${itemCount} items`}
+ aria-label={t('storeItems.cartOpen', { count: itemCount })}
>
{itemCount}
- {itemCount === 1 ? 'item in cart' : 'items in cart'}
+ {itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
)}
@@ -361,7 +362,7 @@ export function StoreSpecialsPage() {
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
- aria-label="Add special category"
+ aria-label={t('website.specialItems.addFab')}
>
@@ -371,6 +372,8 @@ export function StoreSpecialsPage() {
open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateSpecial}
+ title={t('website.specialItems.createTitle')}
+ submitLabel={t('website.create')}
isSubmitting={isSaving}
/>
@@ -378,9 +381,10 @@ export function StoreSpecialsPage() {
open={!!editSpecialTarget}
onClose={() => !isSaving && setEditSpecialTarget(null)}
onSubmit={handleEditSpecial}
+ initialKey={editSpecialTarget?.key ?? ''}
initialTitle={editSpecialTarget?.title ?? ''}
- title="Edit Special Category"
- submitLabel="Save"
+ title={t('website.specialItems.editTitle')}
+ submitLabel={t('website.save')}
isSubmitting={isSaving}
/>
@@ -420,10 +424,10 @@ export function StoreSpecialsPage() {
void confirmDeleteSpecial()}
@@ -432,10 +436,13 @@ export function StoreSpecialsPage() {
void confirmRemoveFromSpecial()}
diff --git a/apps/business/src/pages/WebsiteBadgesPage.tsx b/apps/business/src/pages/WebsiteBadgesPage.tsx
index fffa659..cc23534 100644
--- a/apps/business/src/pages/WebsiteBadgesPage.tsx
+++ b/apps/business/src/pages/WebsiteBadgesPage.tsx
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
+import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
export function WebsiteBadgesPage() {
+ const t = useT()
+
return (
-
- Display certifications, guarantees, and trust signals to website visitors.
-
-
- Badge management will be connected here. Planned options include:
-
+ {t('website.badges.lead')}
+ {t('website.badges.note')}
- Upload badge images with title and link
- Reorder badges for homepage or footer display
- Toggle visibility per badge
+ {t('website.badges.feature.upload')}
+ {t('website.badges.feature.reorder')}
+ {t('website.badges.feature.toggle')}
)
diff --git a/apps/business/src/pages/WebsiteContactPage.tsx b/apps/business/src/pages/WebsiteContactPage.tsx
index 2298e34..670d03b 100644
--- a/apps/business/src/pages/WebsiteContactPage.tsx
+++ b/apps/business/src/pages/WebsiteContactPage.tsx
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { RotateCcw, Search } from 'lucide-react'
+import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
import { Pagination } from '../components/Pagination'
+import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
-import {
- listContactSubmissions,
-} from '../services/contactSubmissionService'
+import { listContactSubmissions } from '../services/contactSubmissionService'
import type {
ContactSubmission,
ContactSubmissionsListResponse,
@@ -19,16 +19,19 @@ import styles from './WebsiteContactPage.module.css'
const PAGE_SIZE = 20
const COLUMN_COUNT = 6
-function formatDateTime(value: string) {
+function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
+ const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return {
- date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
- time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
+ date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
+ time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
}
}
export function WebsiteContactPage() {
+ const t = useT()
+ const { locale } = useLocale()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -58,7 +61,7 @@ export function WebsiteContactPage() {
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
- setError(err instanceof ApiError ? err.message : 'Unable to load contact submissions.')
+ setError(err instanceof ApiError ? err.message : t('website.contact.errorLoad'))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
@@ -109,24 +112,22 @@ export function WebsiteContactPage() {
-
Contact us form
-
- Submissions received from your website contact form.
-
+
{t('title.contactForm')}
+
{t('website.contact.subtitle')}
-
Filters
+
{t('website.contact.filters')}
-
Search
setDraftQuery(e.target.value)}
- placeholder="Title, name, email, cell number, or message"
+ placeholder={t('website.contact.searchPlaceholder')}
+ aria-label={t('website.contact.search')}
onKeyDown={(e) => {
if (e.key === 'Enter') applyFilters()
}}
@@ -139,8 +140,8 @@ export function WebsiteContactPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
- aria-label="Search"
- title="Search"
+ aria-label={t('website.contact.search')}
+ title={t('website.contact.search')}
>
@@ -149,8 +150,8 @@ export function WebsiteContactPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
- aria-label="Clear filters"
- title="Clear filters"
+ aria-label={t('website.contact.clearFilters')}
+ title={t('website.contact.clearFilters')}
>
@@ -161,15 +162,19 @@ export function WebsiteContactPage() {
-
Submissions
+
{t('website.contact.listTitle')}
{data ? (
data.total > 0 ? (
<>
- Showing {showingFrom} - {showingTo} of {data.total}
+ {t('website.contact.showing', {
+ from: showingFrom,
+ to: showingTo,
+ total: data.total,
+ })}
>
) : (
- 'No submissions'
+ t('website.contact.none')
)
) : (
' '
@@ -183,19 +188,19 @@ export function WebsiteContactPage() {
- Title
- Name
- Email
- Cell number
- Date
- Time
+ {t('website.contact.col.title')}
+ {t('website.contact.col.name')}
+ {t('website.contact.col.email')}
+ {t('website.contact.col.cell')}
+ {t('website.contact.col.date')}
+ {t('website.contact.col.time')}
{loading && (
- Loading...
+ {t('website.contact.loading')}
)}
@@ -203,14 +208,14 @@ export function WebsiteContactPage() {
{!loading && data?.items.length === 0 && (
- No submissions found.
+ {t('website.contact.empty')}
)}
{!loading &&
data?.items.map((item) => {
- const { date, time } = formatDateTime(item.createdAt)
+ const { date, time } = formatDateTime(item.createdAt, locale)
return (
{item.title}
{item.name}
@@ -243,7 +248,12 @@ export function WebsiteContactPage() {
- Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
+ {t('website.contact.pageMeta', {
+ page,
+ totalPages,
+ pageSize: PAGE_SIZE,
+ total: data?.total ?? 0,
+ })}
-
- Connect payment gateways so customers can pay online through your store.
-
-
- E-payment settings will be connected here. Planned options include:
-
+ {t('website.ePayment.lead')}
+ {t('website.ePayment.note')}
- Enable or disable online payments
- Configure payment provider credentials
- Set supported payment methods and test mode
+ {t('website.ePayment.feature.enable')}
+ {t('website.ePayment.feature.credentials')}
+ {t('website.ePayment.feature.methods')}
)
diff --git a/apps/business/src/pages/WebsiteFaqPage.tsx b/apps/business/src/pages/WebsiteFaqPage.tsx
index 6737929..8e087b3 100644
--- a/apps/business/src/pages/WebsiteFaqPage.tsx
+++ b/apps/business/src/pages/WebsiteFaqPage.tsx
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
+import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
export function WebsiteFaqPage() {
+ const t = useT()
+
return (
-
- Build an FAQ section to answer common customer questions before they contact you.
-
-
- FAQ editor will be connected here. Planned options include:
-
+ {t('website.faq.lead')}
+ {t('website.faq.note')}
- Add, edit, and reorder questions and answers
- Group items by category
- Publish or hide individual entries
+ {t('website.faq.feature.crud')}
+ {t('website.faq.feature.groups')}
+ {t('website.faq.feature.publish')}
)
diff --git a/apps/business/src/pages/WebsitePage.tsx b/apps/business/src/pages/WebsitePage.tsx
index d64577f..1c6de4d 100644
--- a/apps/business/src/pages/WebsitePage.tsx
+++ b/apps/business/src/pages/WebsitePage.tsx
@@ -11,75 +11,85 @@ import {
} from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
+import { useT } from '../i18n/useT'
+import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
-const websiteSections = [
+const websiteSections: {
+ icon: typeof Images
+ titleKey: BusinessMessageKey
+ descKey: BusinessMessageKey
+ linkKey: BusinessMessageKey
+ href: string
+}[] = [
{
icon: Images,
- title: 'Sliders',
- description: 'Manage homepage banner sliders and promotional image carousels.',
- linkText: 'Manage sliders',
+ titleKey: 'nav.website.sliders',
+ descKey: 'website.card.sliders.desc',
+ linkKey: 'website.card.sliders.link',
href: '/website/sliders',
},
{
icon: LayoutGrid,
- title: 'Special Categories',
- description: 'Highlight selected product categories on your website homepage.',
- linkText: 'Manage categories',
+ titleKey: 'nav.website.specialCategories',
+ descKey: 'website.card.specialCategories.desc',
+ linkKey: 'website.card.specialCategories.link',
href: '/website/special-categories',
},
{
icon: Building2,
- title: 'Special Brands',
- description: 'Showcase partner or featured brands on your website homepage.',
- linkText: 'Manage brands',
+ titleKey: 'nav.website.specialBrands',
+ descKey: 'website.card.specialBrands.desc',
+ linkKey: 'website.card.specialBrands.link',
href: '/website/special-brands',
},
{
icon: Sparkles,
- title: 'Special Items',
- description: 'Curate featured store items into categories for your website carousels.',
- linkText: 'Manage special items',
+ titleKey: 'nav.website.specialItems',
+ descKey: 'website.card.specialItems.desc',
+ linkKey: 'website.card.specialItems.link',
href: '/website/special-items',
},
{
icon: Mail,
- title: 'Contact Us Form',
- description: 'View submissions from your website contact form.',
- linkText: 'View submissions',
+ titleKey: 'nav.website.contact',
+ descKey: 'website.card.contact.desc',
+ linkKey: 'website.card.contact.link',
href: '/website/contact',
},
{
icon: BellRing,
- title: 'Subscriptions',
- description: 'Manage newsletter sign-ups and subscription options for visitors.',
- linkText: 'Manage subscriptions',
+ titleKey: 'nav.website.subscriptions',
+ descKey: 'website.card.subscriptions.desc',
+ linkKey: 'website.card.subscriptions.link',
href: '/website/subscriptions',
},
{
icon: CircleHelp,
- title: 'FAQ',
- description: 'Create and organize frequently asked questions for your website.',
- linkText: 'Manage FAQ',
+ titleKey: 'nav.website.faq',
+ descKey: 'website.card.faq.desc',
+ linkKey: 'website.card.faq.link',
href: '/website/faq',
},
{
icon: Award,
- title: 'Badges',
- description: 'Show trust badges, certifications, and highlights on your website.',
- linkText: 'Manage badges',
+ titleKey: 'nav.website.badges',
+ descKey: 'website.card.badges.desc',
+ linkKey: 'website.card.badges.link',
href: '/website/badges',
},
{
icon: CreditCard,
- title: 'E-Payment',
- description: 'Configure online payment gateways and checkout payment options.',
- linkText: 'Manage e-payment',
+ titleKey: 'nav.website.ePayment',
+ descKey: 'website.card.ePayment.desc',
+ linkKey: 'website.card.ePayment.link',
href: '/website/e-payment',
},
]
export function WebsitePage() {
+ const t = useT()
+
return (
-
Website
-
- Manage public website content, forms, and customer-facing settings.
-
+
{t('title.website')}
+
{t('website.page.subtitle')}
{websiteSections.map((section) => (
-
+
))}
diff --git a/apps/business/src/pages/WebsiteSlidersPage.tsx b/apps/business/src/pages/WebsiteSlidersPage.tsx
index 5368bea..817132c 100644
--- a/apps/business/src/pages/WebsiteSlidersPage.tsx
+++ b/apps/business/src/pages/WebsiteSlidersPage.tsx
@@ -7,6 +7,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { WebsiteSliderGallery } from '../components/WebsiteSliderGallery'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import {
@@ -19,9 +20,8 @@ import type { WebsiteSlider, WebsiteSliderSlide } from '../types/websiteSlider'
import pageStyles from '../components/PageContent.module.css'
import styles from './StoreSpecialsPage.module.css'
-const DEFAULT_SLIDER_TITLE = 'Homepage slider'
-
export function WebsiteSlidersPage() {
+ const t = useT()
const { showToast } = useToast()
const [slider, setSlider] = useState(null)
const [isLoading, setIsLoading] = useState(true)
@@ -48,7 +48,7 @@ export function WebsiteSlidersPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load slides.')
+ setError(t('website.sliders.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -62,7 +62,7 @@ export function WebsiteSlidersPage() {
try {
const imageMediaId = await resolveDataUrlToMediaId(data.image, 'slider-slide.jpg')
if (!imageMediaId) {
- throw new Error('Unable to upload slide image.')
+ throw new Error(t('website.sliders.errorUpload'))
}
const slideInput = {
@@ -79,21 +79,21 @@ export function WebsiteSlidersPage() {
setSlider(result.slider)
} else {
const result = await createWebsiteSlider({
- title: DEFAULT_SLIDER_TITLE,
+ title: t('website.sliders.defaultTitle'),
slides: [slideInput],
})
setSlider(result.slider)
}
setAddSlideOpen(false)
- showToast('Slide added.', 'success')
+ showToast(t('website.sliders.toastAdded'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
- setError('Unable to add slide.')
+ setError(t('website.sliders.errorAdd'))
}
} finally {
setIsSaving(false)
@@ -114,12 +114,12 @@ export function WebsiteSlidersPage() {
const result = await updateWebsiteSlider(slider.id, { slides: nextSlides })
setSlider(result.slider)
setRemoveTarget(null)
- showToast('Slide removed.', 'success')
+ showToast(t('website.sliders.toastRemoved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to remove slide.')
+ setError(t('website.sliders.errorRemove'))
}
} finally {
setIsSaving(false)
@@ -138,17 +138,15 @@ export function WebsiteSlidersPage() {
-
Sliders
-
- Manage homepage banner slides in a 9:4 gallery.
-
+
{t('title.sliders')}
+
{t('website.sliders.subtitle')}
{error && {error}
}
{isLoading ? (
- Loading slides...
+ {t('website.sliders.loading')}
) : (
void confirmRemoveSlide()}
onCancel={() => !isSaving && setRemoveTarget(null)}
/>
diff --git a/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx b/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx
index e62227f..11fb3da 100644
--- a/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx
+++ b/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx
@@ -7,6 +7,7 @@ import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createWebsiteBrandGroup,
@@ -20,6 +21,7 @@ import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
export function WebsiteSpecialBrandsPage() {
+ const t = useT()
const { showToast } = useToast()
const [groups, setGroups] = useState([])
const [isLoading, setIsLoading] = useState(true)
@@ -54,7 +56,7 @@ export function WebsiteSpecialBrandsPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load special brand groups.')
+ setError(t('website.specialBrands.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -65,45 +67,49 @@ export function WebsiteSpecialBrandsPage() {
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
}
- async function handleCreateGroup(title: string) {
+ async function handleCreateGroup(values: { key: string; title: string }) {
setIsSaving(true)
setError('')
try {
const result = await createWebsiteBrandGroup({
- title,
+ key: values.key,
+ title: values.title,
sortOrder: groups.length,
})
setGroups((prev) => [...prev, result.group])
setCreateOpen(false)
- showToast('Special brand group created.', 'success')
+ showToast(t('website.specialBrands.toastCreated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to create special brand group.')
+ setError(t('website.specialBrands.errorCreate'))
}
} finally {
setIsSaving(false)
}
}
- async function handleEditGroup(title: string) {
+ async function handleEditGroup(values: { key: string; title: string }) {
if (!editGroupTarget) return
setIsSaving(true)
setError('')
try {
- const result = await updateWebsiteBrandGroup(editGroupTarget.id, { title })
+ const result = await updateWebsiteBrandGroup(editGroupTarget.id, {
+ key: values.key,
+ title: values.title,
+ })
replaceGroup(result.group)
setEditGroupTarget(null)
- showToast('Special brand group updated.', 'success')
+ showToast(t('website.specialBrands.toastUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to update special brand group.')
+ setError(t('website.specialBrands.errorUpdate'))
}
} finally {
setIsSaving(false)
@@ -120,12 +126,12 @@ export function WebsiteSpecialBrandsPage() {
await deleteWebsiteBrandGroup(deleteGroupTarget.id)
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
setDeleteGroupTarget(null)
- showToast('Special brand group deleted.', 'success')
+ showToast(t('website.specialBrands.toastDeleted'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to delete special brand group.')
+ setError(t('website.specialBrands.errorDelete'))
}
} finally {
setIsSaving(false)
@@ -147,12 +153,12 @@ export function WebsiteSpecialBrandsPage() {
})
replaceGroup(result.group)
setPickItemsTarget(null)
- showToast('Brands added to group.', 'success')
+ showToast(t('website.specialBrands.toastAdded'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to add brands.')
+ setError(t('website.specialBrands.errorAdd'))
}
} finally {
setIsSaving(false)
@@ -174,12 +180,12 @@ export function WebsiteSpecialBrandsPage() {
})
replaceGroup(result.group)
setRemoveTarget(null)
- showToast('Removed from special brand group.', 'success')
+ showToast(t('website.specialBrands.toastRemoved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to remove brand.')
+ setError(t('website.specialBrands.errorRemove'))
}
} finally {
setIsSaving(false)
@@ -198,22 +204,17 @@ export function WebsiteSpecialBrandsPage() {
-
Special Brands
-
- Curate featured brands into groups for your website homepage.
-
+
{t('title.specialBrands')}
+
{t('website.specialBrands.subtitle')}
{error && {error}
}
{isLoading ? (
- Loading special brand groups...
+ {t('website.specialBrands.loading')}
) : groups.length === 0 ? (
-
- No special brand groups yet. Use the + button to create one, then add brands to each
- carousel.
-
+ {t('website.specialBrands.empty')}
) : (
{groups.map((group) => (
@@ -225,9 +226,7 @@ export function WebsiteSpecialBrandsPage() {
onAddItems={() => setPickItemsTarget(group)}
onEditGroup={() => setEditGroupTarget(group)}
onDeleteGroup={() => setDeleteGroupTarget(group)}
- addTooltip={`Add brands to ${group.title}`}
- editTooltip="Edit group"
- deleteTooltip="Delete group"
+ addTooltip={t('website.specialBrands.addTooltip', { title: group.title })}
renderItem={(item) => (
)}
/>
@@ -253,7 +251,7 @@ export function WebsiteSpecialBrandsPage() {
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
- aria-label="Add special brand group"
+ aria-label={t('website.specialBrands.addFab')}
>
@@ -263,7 +261,8 @@ export function WebsiteSpecialBrandsPage() {
open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateGroup}
- title="Add Special Brand Group"
+ title={t('website.specialBrands.createTitle')}
+ submitLabel={t('website.create')}
isSubmitting={isSaving}
/>
@@ -271,9 +270,10 @@ export function WebsiteSpecialBrandsPage() {
open={!!editGroupTarget}
onClose={() => !isSaving && setEditGroupTarget(null)}
onSubmit={handleEditGroup}
+ initialKey={editGroupTarget?.key ?? ''}
initialTitle={editGroupTarget?.title ?? ''}
- title="Edit Special Brand Group"
- submitLabel="Save"
+ title={t('website.specialBrands.editTitle')}
+ submitLabel={t('website.save')}
isSubmitting={isSaving}
/>
@@ -288,10 +288,10 @@ export function WebsiteSpecialBrandsPage() {
void confirmDeleteGroup()}
@@ -300,10 +300,13 @@ export function WebsiteSpecialBrandsPage() {
void confirmRemoveFromGroup()}
diff --git a/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx b/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx
index ccb6cc1..f8aa8ec 100644
--- a/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx
+++ b/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx
@@ -7,6 +7,7 @@ import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
import { useToast } from '../context/ToastContext'
+import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createWebsiteCategoryGroup,
@@ -20,6 +21,7 @@ import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
export function WebsiteSpecialCategoriesPage() {
+ const t = useT()
const { showToast } = useToast()
const [groups, setGroups] = useState([])
const [isLoading, setIsLoading] = useState(true)
@@ -54,7 +56,7 @@ export function WebsiteSpecialCategoriesPage() {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to load special category groups.')
+ setError(t('website.specialCategories.errorLoad'))
}
} finally {
setIsLoading(false)
@@ -65,45 +67,49 @@ export function WebsiteSpecialCategoriesPage() {
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
}
- async function handleCreateGroup(title: string) {
+ async function handleCreateGroup(values: { key: string; title: string }) {
setIsSaving(true)
setError('')
try {
const result = await createWebsiteCategoryGroup({
- title,
+ key: values.key,
+ title: values.title,
sortOrder: groups.length,
})
setGroups((prev) => [...prev, result.group])
setCreateOpen(false)
- showToast('Special category group created.', 'success')
+ showToast(t('website.specialCategories.toastCreated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to create special category group.')
+ setError(t('website.specialCategories.errorCreate'))
}
} finally {
setIsSaving(false)
}
}
- async function handleEditGroup(title: string) {
+ async function handleEditGroup(values: { key: string; title: string }) {
if (!editGroupTarget) return
setIsSaving(true)
setError('')
try {
- const result = await updateWebsiteCategoryGroup(editGroupTarget.id, { title })
+ const result = await updateWebsiteCategoryGroup(editGroupTarget.id, {
+ key: values.key,
+ title: values.title,
+ })
replaceGroup(result.group)
setEditGroupTarget(null)
- showToast('Special category group updated.', 'success')
+ showToast(t('website.specialCategories.toastUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to update special category group.')
+ setError(t('website.specialCategories.errorUpdate'))
}
} finally {
setIsSaving(false)
@@ -120,12 +126,12 @@ export function WebsiteSpecialCategoriesPage() {
await deleteWebsiteCategoryGroup(deleteGroupTarget.id)
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
setDeleteGroupTarget(null)
- showToast('Special category group deleted.', 'success')
+ showToast(t('website.specialCategories.toastDeleted'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to delete special category group.')
+ setError(t('website.specialCategories.errorDelete'))
}
} finally {
setIsSaving(false)
@@ -147,12 +153,12 @@ export function WebsiteSpecialCategoriesPage() {
})
replaceGroup(result.group)
setPickItemsTarget(null)
- showToast('Categories added to group.', 'success')
+ showToast(t('website.specialCategories.toastAdded'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to add categories.')
+ setError(t('website.specialCategories.errorAdd'))
}
} finally {
setIsSaving(false)
@@ -174,12 +180,12 @@ export function WebsiteSpecialCategoriesPage() {
})
replaceGroup(result.group)
setRemoveTarget(null)
- showToast('Removed from special category group.', 'success')
+ showToast(t('website.specialCategories.toastRemoved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
- setError('Unable to remove category.')
+ setError(t('website.specialCategories.errorRemove'))
}
} finally {
setIsSaving(false)
@@ -198,22 +204,17 @@ export function WebsiteSpecialCategoriesPage() {
-
Special Categories
-
- Curate featured product categories into groups for your website homepage.
-
+
{t('title.specialCategories')}
+
{t('website.specialCategories.subtitle')}
{error && {error}
}
{isLoading ? (
- Loading special category groups...
+ {t('website.specialCategories.loading')}
) : groups.length === 0 ? (
-
- No special category groups yet. Use the + button to create one, then add categories to
- each carousel.
-
+ {t('website.specialCategories.empty')}
) : (
{groups.map((group) => (
@@ -225,9 +226,7 @@ export function WebsiteSpecialCategoriesPage() {
onAddItems={() => setPickItemsTarget(group)}
onEditGroup={() => setEditGroupTarget(group)}
onDeleteGroup={() => setDeleteGroupTarget(group)}
- addTooltip={`Add categories to ${group.title}`}
- editTooltip="Edit group"
- deleteTooltip="Delete group"
+ addTooltip={t('website.specialCategories.addTooltip', { title: group.title })}
renderItem={(item) => (
)}
/>
@@ -253,7 +251,7 @@ export function WebsiteSpecialCategoriesPage() {
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
- aria-label="Add special category group"
+ aria-label={t('website.specialCategories.addFab')}
>
@@ -263,7 +261,8 @@ export function WebsiteSpecialCategoriesPage() {
open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateGroup}
- title="Add Special Category Group"
+ title={t('website.specialCategories.createTitle')}
+ submitLabel={t('website.create')}
isSubmitting={isSaving}
/>
@@ -271,9 +270,10 @@ export function WebsiteSpecialCategoriesPage() {
open={!!editGroupTarget}
onClose={() => !isSaving && setEditGroupTarget(null)}
onSubmit={handleEditGroup}
+ initialKey={editGroupTarget?.key ?? ''}
initialTitle={editGroupTarget?.title ?? ''}
- title="Edit Special Category Group"
- submitLabel="Save"
+ title={t('website.specialCategories.editTitle')}
+ submitLabel={t('website.save')}
isSubmitting={isSaving}
/>
@@ -288,10 +288,10 @@ export function WebsiteSpecialCategoriesPage() {
void confirmDeleteGroup()}
@@ -300,10 +300,13 @@ export function WebsiteSpecialCategoriesPage() {
void confirmRemoveFromGroup()}
diff --git a/apps/business/src/pages/WebsiteSubscriptionsPage.tsx b/apps/business/src/pages/WebsiteSubscriptionsPage.tsx
index 42efd98..cea00fa 100644
--- a/apps/business/src/pages/WebsiteSubscriptionsPage.tsx
+++ b/apps/business/src/pages/WebsiteSubscriptionsPage.tsx
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
+import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
export function WebsiteSubscriptionsPage() {
+ const t = useT()
+
return (
-
- Control how visitors subscribe to updates from your business.
-
-
- Subscription management will be connected here. Planned options include:
-
+ {t('website.subscriptions.lead')}
+ {t('website.subscriptions.note')}
- Enable or disable subscription forms
- Custom welcome message and consent text
- Export or view subscriber list
+ {t('website.subscriptions.feature.enable')}
+ {t('website.subscriptions.feature.welcome')}
+ {t('website.subscriptions.feature.export')}
)
diff --git a/apps/business/src/services/blogService.ts b/apps/business/src/services/blogService.ts
index f7a44cb..158cd4a 100644
--- a/apps/business/src/services/blogService.ts
+++ b/apps/business/src/services/blogService.ts
@@ -111,22 +111,28 @@ export function formatBlogAuthor(author: BlogAuthor | null): string {
return name || author.email || 'Unknown author'
}
-export function formatBlogDate(value: string | null): string {
+export function formatBlogDate(
+ value: string | null,
+ locale: 'en' | 'fa' = 'en',
+): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
-export function formatBlogCardDate(value: string | null): string {
- if (!value) return 'Not published'
+export function formatBlogCardDate(
+ value: string | null,
+ locale: 'en' | 'fa' = 'en',
+): string {
+ if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
diff --git a/apps/business/src/services/expertReviewService.ts b/apps/business/src/services/expertReviewService.ts
index af4fe27..291d31a 100644
--- a/apps/business/src/services/expertReviewService.ts
+++ b/apps/business/src/services/expertReviewService.ts
@@ -50,8 +50,8 @@ export function mapExpertReviewApiToUi(review: ExpertReviewApi): ProductExpertRe
}
}
-export function formatReviewDate(iso: string): string {
- return new Date(iso).toLocaleDateString('en-US', {
+export function formatReviewDate(iso: string, locale: 'en' | 'fa' = 'en'): string {
+ return new Date(iso).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
diff --git a/apps/business/src/services/portfolioService.ts b/apps/business/src/services/portfolioService.ts
index a505d02..aeed02e 100644
--- a/apps/business/src/services/portfolioService.ts
+++ b/apps/business/src/services/portfolioService.ts
@@ -113,22 +113,28 @@ export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
}
}
-export function formatPortfolioCardDate(value: string | null): string {
- if (!value) return 'Not published'
+export function formatPortfolioCardDate(
+ value: string | null,
+ locale: 'en' | 'fa' = 'en',
+): string {
+ if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
-export function formatPortfolioDate(value: string | null): string {
+export function formatPortfolioDate(
+ value: string | null,
+ locale: 'en' | 'fa' = 'en',
+): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
diff --git a/apps/business/src/services/productService.ts b/apps/business/src/services/productService.ts
index da8109a..c17eb40 100644
--- a/apps/business/src/services/productService.ts
+++ b/apps/business/src/services/productService.ts
@@ -15,6 +15,7 @@ export interface ProductApi {
status: 'draft' | 'published' | 'archived'
categoryId: string | null
categoryName: string
+ categoryNameFa?: string
brandId: string | null
brand: {
id: string
@@ -84,6 +85,7 @@ export function mapProductApiToUi(product: ProductApi): Product {
nameFa: product.nameFa,
categoryId: product.categoryId ?? '',
category: product.categoryName,
+ categoryFa: product.categoryNameFa || '',
summary: product.summary,
description: product.descriptionHtml,
image: product.image || product.thumbnail || '',
diff --git a/apps/business/src/services/storeSpecialService.ts b/apps/business/src/services/storeSpecialService.ts
index caadd17..b3440d4 100644
--- a/apps/business/src/services/storeSpecialService.ts
+++ b/apps/business/src/services/storeSpecialService.ts
@@ -10,6 +10,7 @@ export interface StoreSpecialsListResponse {
}
export interface CreateStoreSpecialPayload {
+ key: string
title: string
storeItemIds?: string[]
sortOrder?: number
@@ -17,6 +18,7 @@ export interface CreateStoreSpecialPayload {
}
export interface UpdateStoreSpecialPayload {
+ key?: string
title?: string
storeItemIds?: string[]
sortOrder?: number
diff --git a/apps/business/src/types/product.ts b/apps/business/src/types/product.ts
index d9f0993..ce5fc80 100644
--- a/apps/business/src/types/product.ts
+++ b/apps/business/src/types/product.ts
@@ -4,6 +4,7 @@ export interface Product {
nameFa: string
categoryId: string
category: string
+ categoryFa?: string
summary: string
description: string
image: string
diff --git a/apps/business/src/types/storeSpecial.ts b/apps/business/src/types/storeSpecial.ts
index d91bb2c..2e3847b 100644
--- a/apps/business/src/types/storeSpecial.ts
+++ b/apps/business/src/types/storeSpecial.ts
@@ -25,6 +25,7 @@ export interface StoreSpecialStoreItem {
export interface StoreSpecial {
id: string
+ key: string
title: string
sortOrder: number
isActive: boolean
diff --git a/apps/business/src/types/websiteBrandGroup.ts b/apps/business/src/types/websiteBrandGroup.ts
index acefb20..44113f1 100644
--- a/apps/business/src/types/websiteBrandGroup.ts
+++ b/apps/business/src/types/websiteBrandGroup.ts
@@ -11,6 +11,7 @@ export interface WebsiteBrandGroupItem {
export interface WebsiteBrandGroup {
id: string
+ key: string
title: string
sortOrder: number
isActive: boolean
@@ -27,6 +28,7 @@ export interface WebsiteBrandGroupsListResponse {
}
export interface CreateWebsiteBrandGroupPayload {
+ key: string
title: string
brandIds?: string[]
sortOrder?: number
@@ -34,6 +36,7 @@ export interface CreateWebsiteBrandGroupPayload {
}
export interface UpdateWebsiteBrandGroupPayload {
+ key?: string
title?: string
brandIds?: string[]
sortOrder?: number
diff --git a/apps/business/src/types/websiteCategoryGroup.ts b/apps/business/src/types/websiteCategoryGroup.ts
index 08f8dcc..fe74923 100644
--- a/apps/business/src/types/websiteCategoryGroup.ts
+++ b/apps/business/src/types/websiteCategoryGroup.ts
@@ -9,6 +9,7 @@ export interface WebsiteCategoryGroupItem {
export interface WebsiteCategoryGroup {
id: string
+ key: string
title: string
sortOrder: number
isActive: boolean
@@ -25,6 +26,7 @@ export interface WebsiteCategoryGroupsListResponse {
}
export interface CreateWebsiteCategoryGroupPayload {
+ key: string
title: string
categoryIds?: string[]
sortOrder?: number
@@ -32,6 +34,7 @@ export interface CreateWebsiteCategoryGroupPayload {
}
export interface UpdateWebsiteCategoryGroupPayload {
+ key?: string
title?: string
categoryIds?: string[]
sortOrder?: number
diff --git a/apps/business/src/utils/storeProductGroups.ts b/apps/business/src/utils/storeProductGroups.ts
index 9894b28..5a537df 100644
--- a/apps/business/src/utils/storeProductGroups.ts
+++ b/apps/business/src/utils/storeProductGroups.ts
@@ -93,6 +93,9 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
)
}
-export function formatVariantCount(count: number): string {
+export function formatVariantCount(count: number, locale: 'en' | 'fa' = 'en'): string {
+ if (locale === 'fa') {
+ return count === 1 ? '۱ تنوع' : `${count} تنوع`
+ }
return count === 1 ? '1 variant' : `${count} variants`
}
diff --git a/apps/super-admin/src/pages/WebsitesPage.tsx b/apps/super-admin/src/pages/WebsitesPage.tsx
index fd70cba..cb31ddc 100644
--- a/apps/super-admin/src/pages/WebsitesPage.tsx
+++ b/apps/super-admin/src/pages/WebsitesPage.tsx
@@ -5,6 +5,7 @@ import {
Loader2,
Lock,
Pencil,
+ RefreshCw,
Rocket,
RotateCcw,
Search,
@@ -23,6 +24,7 @@ import {
removeDomain,
setDomainActive,
setDomainSsl,
+ syncSsl,
updateDomain,
} from '../services/domainService'
import { useToast } from '../context/ToastContext'
@@ -102,6 +104,7 @@ export function WebsitesPage() {
const [togglingActiveId, setTogglingActiveId] = useState(null)
const [togglingSslId, setTogglingSslId] = useState(null)
const [deployingId, setDeployingId] = useState(null)
+ const [syncingSsl, setSyncingSsl] = useState(false)
useEffect(() => {
const controller = new AbortController()
@@ -260,6 +263,27 @@ export function WebsitesPage() {
}
}
+ async function handleSyncSsl() {
+ if (syncingSsl) return
+
+ flushSync(() => {
+ setSyncingSsl(true)
+ })
+ setError('')
+ showToast('Starting SSL sync…', 'info')
+
+ try {
+ const result = await syncSsl()
+ showToast(result.message || 'SSL sync started.', 'success')
+ } catch (err) {
+ const message = err instanceof ApiError ? err.message : 'Unable to start SSL sync.'
+ setError(message)
+ showToast(message, 'error')
+ } finally {
+ setSyncingSsl(false)
+ }
+ }
+
async function handleDeploy(domain: DomainListItem) {
if (!domain.deploySlug) return
if (deployingId != null) return
@@ -342,6 +366,22 @@ export function WebsitesPage() {
Monitor and manage all domains across the platform.
+ void handleSyncSsl()}
+ disabled={syncingSsl}
+ aria-busy={syncingSsl}
+ >
+ {syncingSsl ? (
+
+ ) : (
+
+ )}
+ {syncingSsl ? 'Syncing SSL…' : 'Sync SSL now'}
+
diff --git a/apps/super-admin/src/services/domainService.ts b/apps/super-admin/src/services/domainService.ts
index ac2e0f1..2e82e59 100644
--- a/apps/super-admin/src/services/domainService.ts
+++ b/apps/super-admin/src/services/domainService.ts
@@ -1,5 +1,5 @@
import { apiRequest } from '../lib/api'
-import type { DeployDomainResponse, DomainsListResponse } from '../types/domain'
+import type { DeployDomainResponse, DomainsListResponse, SyncSslResponse } from '../types/domain'
export interface ListDomainsParams {
page?: number
@@ -60,3 +60,10 @@ export async function deployDomain(domainId: number | string) {
auth: true,
})
}
+
+export async function syncSsl() {
+ return apiRequest
('/domains/ssl-sync', {
+ method: 'POST',
+ auth: true,
+ })
+}
diff --git a/apps/super-admin/src/types/domain.ts b/apps/super-admin/src/types/domain.ts
index e45f863..2f41da1 100644
--- a/apps/super-admin/src/types/domain.ts
+++ b/apps/super-admin/src/types/domain.ts
@@ -30,3 +30,8 @@ export interface DeployDomainResponse {
lastDeployedAt: string | null
lastDeployStatus: DomainDeployStatus | null
}
+
+export interface SyncSslResponse {
+ status: 'accepted'
+ message: string
+}
diff --git a/deploy/meshkee-ssl-sync-agent.service b/deploy/meshkee-ssl-sync-agent.service
new file mode 100644
index 0000000..30f2696
--- /dev/null
+++ b/deploy/meshkee-ssl-sync-agent.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=Meshkee dashboards SSL sync agent
+After=network.target
+
+[Service]
+Type=simple
+EnvironmentFile=/etc/meshkee/ssl-sync-agent.env
+ExecStart=/usr/bin/node /opt/meshkee/dashboards/deploy/ssl-sync-agent.mjs
+Restart=on-failure
+RestartSec=3
+User=root
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/ssl-sync-agent.mjs b/deploy/ssl-sync-agent.mjs
new file mode 100644
index 0000000..277b4e2
--- /dev/null
+++ b/deploy/ssl-sync-agent.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+/**
+ * Tiny HTTP agent on the dashboards VPS.
+ * Super Admin → Nest API → POST here → runs ssl-sync.sh in the background.
+ *
+ * Env (/etc/meshkee/ssl-sync-agent.env):
+ * SSL_SYNC_AGENT_TOKEN=...
+ * SSL_SYNC_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-sync.sh
+ * PORT=9051
+ * BIND=0.0.0.0
+ */
+import { createServer } from 'node:http'
+import { spawn } from 'node:child_process'
+import { accessSync, constants } from 'node:fs'
+
+const TOKEN = (process.env.SSL_SYNC_AGENT_TOKEN || '').trim()
+const SCRIPT =
+ (process.env.SSL_SYNC_SCRIPT || '/opt/meshkee/dashboards/deploy/ssl-sync.sh').trim()
+const PORT = Number(process.env.PORT || 9051)
+const BIND = (process.env.BIND || '0.0.0.0').trim()
+
+if (!TOKEN) {
+ console.error('SSL_SYNC_AGENT_TOKEN is required')
+ process.exit(1)
+}
+
+try {
+ accessSync(SCRIPT, constants.X_OK)
+} catch {
+ console.error(`SSL sync script missing or not executable: ${SCRIPT}`)
+ process.exit(1)
+}
+
+let running = false
+
+function json(res, status, body) {
+ const payload = JSON.stringify(body)
+ res.writeHead(status, {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(payload),
+ })
+ res.end(payload)
+}
+
+function startSync() {
+ running = true
+ const child = spawn(SCRIPT, [], {
+ detached: true,
+ stdio: 'ignore',
+ env: process.env,
+ })
+ child.on('error', (err) => {
+ console.error(`${new Date().toISOString()} spawn error:`, err.message)
+ running = false
+ })
+ child.on('exit', (code, signal) => {
+ console.log(
+ `${new Date().toISOString()} ssl-sync finished code=${code} signal=${signal ?? ''}`,
+ )
+ running = false
+ })
+ child.unref()
+}
+
+const server = createServer((req, res) => {
+ if (req.method === 'GET' && req.url === '/health') {
+ return json(res, 200, { ok: true, running })
+ }
+
+ if (req.method !== 'POST' || req.url !== '/ssl-sync') {
+ return json(res, 404, { error: 'not found' })
+ }
+
+ const provided = String(req.headers['x-ssl-sync-agent-token'] ?? '').trim()
+ if (!provided || provided !== TOKEN) {
+ return json(res, 401, { error: 'unauthorized' })
+ }
+
+ if (running) {
+ return json(res, 409, { error: 'ssl sync already running' })
+ }
+
+ startSync()
+ console.log(`${new Date().toISOString()} ssl-sync accepted`)
+ return json(res, 202, {
+ status: 'accepted',
+ message: 'SSL sync started',
+ })
+})
+
+server.listen(PORT, BIND, () => {
+ console.log(`ssl-sync-agent listening on ${BIND}:${PORT}`)
+})
diff --git a/deploy/ssl-sync.sh b/deploy/ssl-sync.sh
index 71552f6..d58bda2 100644
--- a/deploy/ssl-sync.sh
+++ b/deploy/ssl-sync.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Sync Let's Encrypt cert SANs with dashboard hosts from the API.
-# Cron: 0 */2 * * * /opt/meshkee/dashboards/scripts/ssl-sync.sh >> /var/log/meshkee-ssl-sync.log 2>&1
+# Cron: 0 */2 * * * /opt/meshkee/dashboards/deploy/ssl-sync.sh >> /var/log/meshkee-ssl-sync.log 2>&1
set -euo pipefail
CONF=/etc/meshkee/ssl-sync.env
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 89149dd..ffc2376 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -15,10 +15,48 @@ API: `https://api.meshkee.com/api/v1` (separate server).
1. Add business + domain in Super Admin (apex, e.g. `sanihome.ir`)
2. Add Arvan **A** records (DNS-only): `business` + `customer` → dashboards VPS IP
-3. Within ~2 hours, `/opt/meshkee/dashboards/scripts/ssl-sync.sh` expands the cert via
- `GET https://api.meshkee.com/api/v1/internal/ssl/hosts` (`X-SSL-Sync-Token`)
+3. In Super Admin → **Websites** → **Sync SSL now** (or wait for cron ~2h)
-Manual: `sudo /opt/meshkee/dashboards/scripts/ssl-sync.sh`
+Manual on the VPS: `sudo /opt/meshkee/dashboards/deploy/ssl-sync.sh`
+
+Cron (root):
+
+```
+0 */2 * * * /opt/meshkee/dashboards/deploy/ssl-sync.sh >> /var/log/meshkee-ssl-sync.log 2>&1
+```
+
+### SSL sync agent (on-demand from Super Admin)
+
+Small Node agent on the dashboards VPS; Nest calls it after the button is clicked.
+
+1. Files under `/opt/meshkee/dashboards/deploy/`: `ssl-sync.sh`, `ssl-sync-agent.mjs`, `meshkee-ssl-sync-agent.service`
+2. Make the script executable: `chmod +x /opt/meshkee/dashboards/deploy/ssl-sync.sh`
+3. Agent env `/etc/meshkee/ssl-sync-agent.env`:
+
+```
+SSL_SYNC_AGENT_TOKEN=
+SSL_SYNC_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-sync.sh
+PORT=9051
+BIND=0.0.0.0
+```
+
+4. Install + start:
+
+```bash
+cp /opt/meshkee/dashboards/deploy/meshkee-ssl-sync-agent.service /etc/systemd/system/
+systemctl daemon-reload
+systemctl enable --now meshkee-ssl-sync-agent
+ufw allow from 185.164.72.119 to any port 9051 proto tcp comment 'SSL sync agent from API'
+```
+
+5. API `.env`:
+
+```
+SSL_SYNC_AGENT_URL=http://45.149.76.52:9051/ssl-sync
+SSL_SYNC_AGENT_TOKEN=
+```
+
+Endpoint used by the UI: `POST /api/v1/domains/ssl-sync` (super-admin JWT).
## Redeploy frontends
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index f71e373..03f00e2 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -492,7 +492,7 @@ Migration: `database/migrations/033_business_favicon.sql`
| API | `https://api.meshkee.com/api/v1` (host `185.164.72.119`) |
| Docs | `docs/DEPLOY.md` |
-SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h (option A: `GET /api/v1/internal/ssl/hosts`).
+SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h + Super Admin **Sync SSL now** (`POST /domains/ssl-sync` → dashboards agent).
---
diff --git a/packages/dashboard-ui/src/components/Pagination.module.css b/packages/dashboard-ui/src/components/Pagination.module.css
index c48c905..ebde6eb 100644
--- a/packages/dashboard-ui/src/components/Pagination.module.css
+++ b/packages/dashboard-ui/src/components/Pagination.module.css
@@ -16,9 +16,12 @@
.navBtn {
width: 38px;
height: 38px;
- display: flex;
+ box-sizing: border-box;
+ display: inline-flex;
align-items: center;
justify-content: center;
+ padding: 0;
+ line-height: 0;
border-radius: var(--radius-sm);
color: var(--text-secondary);
background: var(--glass-bg);
@@ -26,6 +29,11 @@
transition: background 0.2s, color 0.2s, opacity 0.2s;
}
+.navBtn svg {
+ display: block;
+ flex-shrink: 0;
+}
+
.navBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
@@ -45,9 +53,15 @@
.pageBtn {
min-width: 38px;
height: 38px;
- padding: 0 10px;
+ box-sizing: border-box;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
font-size: 14px;
font-weight: 500;
+ font-family: var(--font-ui, inherit);
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
diff --git a/packages/dashboard-ui/src/components/Pagination.tsx b/packages/dashboard-ui/src/components/Pagination.tsx
index 86f01b6..5a35bb7 100644
--- a/packages/dashboard-ui/src/components/Pagination.tsx
+++ b/packages/dashboard-ui/src/components/Pagination.tsx
@@ -1,4 +1,6 @@
+import { useContext } from 'react'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
+import { LocaleContext } from '../context/LocaleContext'
import styles from './Pagination.module.css'
export interface PaginationProps {
@@ -38,6 +40,12 @@ export function Pagination({
variant = 'default',
className,
}: PaginationProps) {
+ const locale = useContext(LocaleContext)
+ const dir =
+ locale?.dir ??
+ (typeof document !== 'undefined' && document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr')
+ const isRtl = dir === 'rtl'
+
if (totalPages <= 1) return null
const pages = buildPageWindow(currentPage, totalPages, siblingCount)
@@ -49,8 +57,13 @@ export function Pagination({
.filter(Boolean)
.join(' ')
+ const FirstIcon = isRtl ? ChevronsRight : ChevronsLeft
+ const PrevIcon = isRtl ? ChevronRight : ChevronLeft
+ const NextIcon = isRtl ? ChevronLeft : ChevronRight
+ const LastIcon = isRtl ? ChevronsLeft : ChevronsRight
+
return (
-
+
-
+
-
+
@@ -94,7 +107,7 @@ export function Pagination({
disabled={disabled || currentPage === totalPages}
aria-label="Next page"
>
-
+
-
+
)
diff --git a/packages/dashboard-ui/src/context/LocaleContext.tsx b/packages/dashboard-ui/src/context/LocaleContext.tsx
index ec9b18a..76b4358 100644
--- a/packages/dashboard-ui/src/context/LocaleContext.tsx
+++ b/packages/dashboard-ui/src/context/LocaleContext.tsx
@@ -24,6 +24,8 @@ interface LocaleContextValue {
const LocaleContext = createContext(null)
+export { LocaleContext }
+
export function LocaleProvider({
children,
defaultLocale = 'fa',