Add FA/EN locale, home activity charts, and theme-aware polish.

Ship shared LocaleProvider, business/customer i18n, branding defaultLocale, curated home tiles with dual 30-day charts, and chart/page aura tokens; refresh PROJECT_CONTEXT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-01 09:35:27 +03:30
co-authored by Cursor
parent f5b2193ba1
commit 66004a0fba
112 changed files with 4338 additions and 1061 deletions
+3
View File
@@ -4,6 +4,7 @@ import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { AuthProvider } from './context/AuthContext'
import { BusinessThemeProvider } from './context/BusinessThemeContext'
import { TenantBrandingProvider } from './context/TenantBrandingContext'
import { LocaleProvider } from '@meshkee/dashboard-ui'
import { ToastProvider } from './context/ToastContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
@@ -50,6 +51,7 @@ import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
function App() {
return (
<BusinessDomainGuard>
<LocaleProvider>
<BrowserRouter>
<TenantBrandingProvider>
<DashboardDocumentTitle />
@@ -111,6 +113,7 @@ function App() {
</AuthProvider>
</TenantBrandingProvider>
</BrowserRouter>
</LocaleProvider>
</BusinessDomainGuard>
)
}
@@ -21,6 +21,10 @@
color: var(--text-muted);
}
:global([dir='rtl']) .separator {
transform: scaleX(-1);
}
.link {
font-size: 14px;
font-weight: 500;
+10 -3
View File
@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { translateBreadcrumbLabel } from '../i18n/messages'
import { useT } from '../i18n/useT'
import styles from './Breadcrumbs.module.css'
export interface BreadcrumbItem {
@@ -12,11 +15,15 @@ interface BreadcrumbsProps {
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
const { locale } = useLocale()
const t = useT()
return (
<nav className={styles.breadcrumbs} aria-label="Breadcrumb">
<nav className={styles.breadcrumbs} aria-label={t('common.breadcrumb')}>
<ol className={styles.list}>
{items.map((item, index) => {
const isLast = index === items.length - 1
const label = translateBreadcrumbLabel(locale, item.label)
return (
<li key={`${item.label}-${index}`} className={styles.item}>
{index > 0 && (
@@ -24,10 +31,10 @@ export function Breadcrumbs({ items }: BreadcrumbsProps) {
)}
{item.href && !isLast ? (
<Link to={item.href} className={styles.link}>
{item.label}
{label}
</Link>
) : (
<span className={isLast ? styles.current : styles.text}>{item.label}</span>
<span className={isLast ? styles.current : styles.text}>{label}</span>
)}
</li>
)
@@ -46,16 +46,24 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 4px;
margin-inline-start: 4px;
color: var(--text-muted);
transition: transform 0.2s ease;
vertical-align: middle;
}
:global([dir='rtl']) .chevron {
transform: scaleX(-1);
}
.chevronOpen {
transform: rotate(90deg);
}
:global([dir='rtl']) .chevronOpen {
transform: scaleX(-1) rotate(90deg);
}
.names {
display: flex;
align-items: center;
@@ -75,7 +83,7 @@
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
@@ -0,0 +1,138 @@
.card {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 24px;
height: 100%;
box-sizing: border-box;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.legendItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legendDot {
width: 10px;
height: 10px;
border-radius: 3px;
}
.tonePrimary {
background: var(--primary);
}
.toneAccent {
background: var(--chart-accent, #a855f7);
}
.status,
.error {
font-size: 14px;
text-align: center;
padding: 40px 12px;
}
.status {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
.chartWrap {
overflow-x: auto;
}
.chart {
display: flex;
align-items: flex-end;
gap: 4px;
min-width: 100%;
padding-bottom: 4px;
}
.group {
flex: 1;
min-width: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bars {
display: flex;
align-items: flex-end;
height: 180px;
gap: 2px;
}
.bar {
width: 7px;
border-radius: 4px 4px 2px 2px;
transition: height 0.3s ease;
}
.barPrimary {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--primary) 55%, #ffffff) 0%,
var(--primary) 100%
);
}
.barAccent {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--chart-accent, #a855f7) 55%, #ffffff) 0%,
var(--chart-accent, #a855f7) 100%
);
}
.label {
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
font-family: var(--font-en), var(--font-ui), sans-serif;
line-height: 1;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
}
}
@@ -0,0 +1,164 @@
import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import type {
DailyActivityPoint,
DualDailyActivityResponse,
} from '../services/dailyActivityService'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css'
const CHART_HEIGHT = 180
const BAR_GAP = 2
interface DailyActivityChartProps {
titleKey: BusinessMessageKey
subtitleKey: BusinessMessageKey
primaryLegendKey: BusinessMessageKey
secondaryLegendKey: BusinessMessageKey
loadingKey: BusinessMessageKey
errorKey: BusinessMessageKey
primaryBarTitleKey: BusinessMessageKey
secondaryBarTitleKey: BusinessMessageKey
load: (signal: AbortSignal) => Promise<DualDailyActivityResponse>
}
function formatDayLabel(dateKey: string, locale: string): string {
const [year, month, day] = dateKey.split('-').map(Number)
const date = new Date(year, month - 1, day)
return date.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
day: 'numeric',
numberingSystem: 'latn',
})
}
export function DailyActivityChart({
titleKey,
subtitleKey,
primaryLegendKey,
secondaryLegendKey,
loadingKey,
errorKey,
primaryBarTitleKey,
secondaryBarTitleKey,
load,
}: DailyActivityChartProps) {
const t = useT()
const { locale } = useLocale()
const [primaryItems, setPrimaryItems] = useState<DailyActivityPoint[]>([])
const [secondaryItems, setSecondaryItems] = useState<DailyActivityPoint[]>([])
const [primaryTotal, setPrimaryTotal] = useState(0)
const [secondaryTotal, setSecondaryTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
async function run() {
setIsLoading(true)
setError('')
try {
const data = await load(controller.signal)
if (controller.signal.aborted) return
setPrimaryItems(data.primary.items)
setSecondaryItems(data.secondary.items)
setPrimaryTotal(data.primary.total)
setSecondaryTotal(data.secondary.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t(errorKey))
}
} finally {
if (!controller.signal.aborted) setIsLoading(false)
}
}
void run()
return () => controller.abort()
}, [load, t, errorKey])
const maxValue = useMemo(() => {
const peak = Math.max(
...primaryItems.map((item) => item.count),
...secondaryItems.map((item) => item.count),
0,
)
return peak > 0 ? peak : 1
}, [primaryItems, secondaryItems])
return (
<section className={styles.card} aria-label={t(titleKey)}>
<div className={styles.header}>
<div>
<h3 className={styles.title}>{t(titleKey)}</h3>
<p className={styles.subtitle}>{t(subtitleKey)}</p>
</div>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.tonePrimary}`} />
{t(primaryLegendKey, { count: primaryTotal })}
</span>
<span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.toneAccent}`} />
{t(secondaryLegendKey, { count: secondaryTotal })}
</span>
</div>
</div>
{isLoading ? (
<p className={styles.status}>{t(loadingKey)}</p>
) : error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : (
<div className={styles.chartWrap}>
<div
className={styles.chart}
style={{ height: CHART_HEIGHT + 28 }}
role="img"
aria-label={t(titleKey)}
>
{primaryItems.map((item, index) => {
const secondary = secondaryItems[index]
const secondaryCount = secondary?.count ?? 0
const primaryHeight = (item.count / maxValue) * CHART_HEIGHT
const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT
const label = formatDayLabel(item.date, locale)
return (
<div key={item.date} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barPrimary}`}
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
title={t(primaryBarTitleKey, { day: label, count: item.count })}
/>
<div
className={`${styles.bar} ${styles.barAccent}`}
style={{
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
}}
title={t(secondaryBarTitleKey, {
day: label,
count: secondaryCount,
})}
/>
</div>
<span className={styles.label} lang="en" dir="ltr">
{label}
</span>
</div>
)
})}
</div>
</div>
)}
</section>
)
}
@@ -1,17 +1,18 @@
import { useLocation } from 'react-router-dom'
import { useDashboardDocumentTitle } from '@meshkee/dashboard-ui'
import { useDashboardDocumentTitle, useLocale } from '@meshkee/dashboard-ui'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { BUSINESS_DASHBOARD_NAME, businessRouteTitleRules } from '../lib/routeTitles'
import { getBusinessRouteTitleRules, translate } from '../i18n/messages'
export function DashboardDocumentTitle() {
const { pathname } = useLocation()
const { businessName } = useTenantBranding()
const { locale } = useLocale()
useDashboardDocumentTitle({
businessName,
dashboardName: BUSINESS_DASHBOARD_NAME,
dashboardName: translate(locale, 'app.dashboardName'),
pathname,
routeRules: businessRouteTitleRules,
routeRules: getBusinessRouteTitleRules(locale),
})
return null
+8 -12
View File
@@ -60,9 +60,10 @@
.badge {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
inset-inline-end: 4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
@@ -70,7 +71,8 @@
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 50%;
line-height: 1;
border-radius: 999px;
border: 2px solid white;
}
@@ -82,7 +84,8 @@
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px 6px 6px;
padding-block: 6px;
padding-inline: 6px 12px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--glass-border);
@@ -96,13 +99,6 @@
border-color: rgba(var(--primary-rgb) / 0.25);
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo {
display: flex;
flex-direction: column;
@@ -132,7 +128,7 @@
.dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
inset-inline-end: 0;
min-width: 180px;
padding: 6px;
background: rgba(255, 255, 255, 0.95);
+59 -35
View File
@@ -1,32 +1,52 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, Settings, KeyRound, LogOut } from 'lucide-react'
import { PasswordResetModal } from '@meshkee/dashboard-ui'
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { changePassword } from '../services/authService'
import styles from './Header.module.css'
const profileMenuItems = [
{ icon: User, label: 'Profile', to: '/profile' },
{ icon: Settings, label: 'Setting', to: '/settings' },
]
function displayUserName(
user: {
firstName: string | null
lastName: string | null
firstNameEn?: string | null
lastNameEn?: string | null
cellNumber: string
} | null,
locale: 'en' | 'fa',
fallback: string,
) {
if (!user) return fallback
const localized =
locale === 'en'
? [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ')
: [user.firstName, user.lastName].filter(Boolean).join(' ')
const other =
locale === 'en'
? [user.firstName, user.lastName].filter(Boolean).join(' ')
: [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ')
return localized || other || user.cellNumber || fallback
}
export function Header() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const displayName =
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.cellNumber || 'User'
const displayName = displayUserName(user, locale, t('app.userFallback'))
const roleLabel =
user?.roleLabel ??
(user?.isSuperAdmin || user?.roles.includes('super_admin')
? 'Super Admin'
? t('role.superAdmin')
: user?.businesses[0]?.isOwner
? 'Business Owner'
: user?.businesses[0]?.teamRole ?? 'Staff')
? t('role.owner')
: user?.businesses[0]?.teamRole ?? t('role.staff'))
useEffect(() => {
if (!menuOpen) return
@@ -64,21 +84,23 @@ export function Header() {
<>
<header className={styles.header}>
<div className={styles.left}>
<button className={styles.menuBtn} aria-label="Toggle menu">
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
<h1 className={styles.title}>Admin Dashboard</h1>
<h1 className={styles.title}>{t('header.title')}</h1>
</div>
<div className={styles.right}>
<button className={styles.iconBtn} aria-label="Messages">
<LanguageSelect />
<button className={styles.iconBtn} aria-label={t('header.messages')}>
<MessageSquare size={20} />
<span className={styles.badge}>5</span>
<span className={styles.badge}>0</span>
</button>
<button className={styles.iconBtn} aria-label="Notifications">
<button className={styles.iconBtn} aria-label={t('header.notifications')}>
<Bell size={20} />
<span className={styles.badge}>3</span>
<span className={styles.badge}>0</span>
</button>
<div className={styles.profileWrap} ref={menuRef}>
@@ -89,11 +111,6 @@ export function Header() {
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<img
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(displayName)}`}
alt={displayName}
className={styles.avatar}
/>
<div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span>
<span className={styles.role}>{roleLabel}</span>
@@ -106,18 +123,24 @@ export function Header() {
{menuOpen && (
<div className={styles.dropdown} role="menu">
{profileMenuItems.map(({ icon: Icon, label, to }) => (
<Link
key={label}
to={to}
className={styles.dropdownItem}
role="menuitem"
onClick={() => setMenuOpen(false)}
>
<Icon size={16} />
<span>{label}</span>
</Link>
))}
<Link
to="/profile"
className={styles.dropdownItem}
role="menuitem"
onClick={() => setMenuOpen(false)}
>
<User size={16} />
<span>{t('header.profile')}</span>
</Link>
<Link
to="/settings"
className={styles.dropdownItem}
role="menuitem"
onClick={() => setMenuOpen(false)}
>
<Settings size={16} />
<span>{t('header.setting')}</span>
</Link>
<button
type="button"
className={styles.dropdownItem}
@@ -125,7 +148,7 @@ export function Header() {
onClick={openPasswordModal}
>
<KeyRound size={16} />
<span>Change password</span>
<span>{t('header.changePassword')}</span>
</button>
<button
type="button"
@@ -134,7 +157,7 @@ export function Header() {
onClick={handleLogout}
>
<LogOut size={16} />
<span>Logout</span>
<span>{t('nav.logout')}</span>
</button>
</div>
)}
@@ -146,6 +169,7 @@ export function Header() {
open={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
onChangePassword={changePassword}
title={t('header.changePassword')}
/>
</>
)
@@ -4,7 +4,7 @@
}
.main {
margin-left: var(--sidebar-width);
margin-inline-start: var(--sidebar-width);
min-height: 100vh;
position: relative;
z-index: 1;
@@ -12,6 +12,6 @@
@media (max-width: 768px) {
.main {
margin-left: 0;
margin-inline-start: 0;
}
}
@@ -64,7 +64,7 @@
.titleFa {
margin: 0;
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 15px;
font-weight: 500;
line-height: 1.4;
+17 -1
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, MessageSquare, Trash2 } from 'lucide-react'
import { ChevronUp, Pencil, MessageSquare, Trash2 } from 'lucide-react'
import type { Portfolio } from '../types/portfolio'
import { formatPortfolioCardDate } from '../services/portfolioService'
import { textLocaleAttrs } from '../utils/textLocale'
@@ -10,6 +10,9 @@ import controlStyles from './ProductCard.module.css'
interface PortfolioCardProps {
portfolio: Portfolio
commentCount: number
canMoveUp: boolean
isMovingUp?: boolean
onMoveUp: (id: string) => void
onEdit: (id: string) => void
onComments: (id: string) => void
onRemove: (id: string) => void
@@ -18,6 +21,9 @@ interface PortfolioCardProps {
export function PortfolioCard({
portfolio,
commentCount,
canMoveUp,
isMovingUp = false,
onMoveUp,
onEdit,
onComments,
onRemove,
@@ -87,6 +93,16 @@ export function PortfolioCard({
</Link>
<div className={controlStyles.controls}>
<Tooltip label="Move up">
<button
type="button"
onClick={() => onMoveUp(portfolio.id)}
disabled={!canMoveUp || isMovingUp}
aria-label="Move up"
>
<ChevronUp size={16} />
</button>
</Tooltip>
<Tooltip label="Edit portfolio">
<button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit">
<Pencil size={16} />
@@ -53,7 +53,7 @@
}
.legendUpdated {
background: var(--primary-dark);
background: var(--chart-accent, #a855f7);
}
.status,
@@ -115,8 +115,8 @@
.barUpdated {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--primary-dark) 55%, #ffffff) 0%,
var(--primary-dark) 100%
color-mix(in srgb, var(--chart-accent, #a855f7) 55%, #ffffff) 0%,
var(--chart-accent, #a855f7) 100%
);
}
@@ -1,13 +1,17 @@
import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import { aggregateProductActivity, type ProductMonthActivity } from '../utils/productActivity'
import { useT } from '../i18n/useT'
import styles from './ProductActivityChart.module.css'
const CHART_HEIGHT = 200
const BAR_GAP = 6
export function ProductActivityChart() {
const t = useT()
const { locale } = useLocale()
const [data, setData] = useState<ProductMonthActivity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
@@ -16,7 +20,7 @@ export function ProductActivityChart() {
const controller = new AbortController()
void loadActivity(controller.signal)
return () => controller.abort()
}, [])
}, [locale])
async function loadActivity(signal?: AbortSignal) {
setIsLoading(true)
@@ -24,13 +28,13 @@ export function ProductActivityChart() {
try {
const products = await listAllProducts(signal)
setData(aggregateProductActivity(products))
setData(aggregateProductActivity(products, locale))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product activity.')
setError(t('products.activity.error'))
}
} finally {
setIsLoading(false)
@@ -51,26 +55,26 @@ export function ProductActivityChart() {
)
return (
<section className={styles.card} aria-label="Product activity chart">
<section className={styles.card} aria-label={t('products.activity.title')}>
<div className={styles.header}>
<div>
<h3 className={styles.title}>Product activity</h3>
<p className={styles.subtitle}>Products added or updated in the last 12 months</p>
<h3 className={styles.title}>{t('products.activity.title')}</h3>
<p className={styles.subtitle}>{t('products.activity.subtitle')}</p>
</div>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.legendAdded}`} />
Added ({totals.added})
{t('products.activity.added', { count: totals.added })}
</span>
<span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.legendUpdated}`} />
Updated ({totals.updated})
{t('products.activity.updated', { count: totals.updated })}
</span>
</div>
</div>
{isLoading ? (
<p className={styles.status}>Loading chart...</p>
<p className={styles.status}>{t('products.activity.loading')}</p>
) : error ? (
<p className={styles.error} role="alert">
{error}
@@ -81,7 +85,7 @@ export function ProductActivityChart() {
className={styles.chart}
style={{ height: CHART_HEIGHT + 32 }}
role="img"
aria-label="Bar chart of products added and updated per month"
aria-label={t('products.activity.chartAria')}
>
{data.map((item) => {
const addedHeight = (item.added / maxValue) * CHART_HEIGHT
@@ -93,12 +97,18 @@ export function ProductActivityChart() {
<div
className={`${styles.bar} ${styles.barAdded}`}
style={{ height: Math.max(addedHeight, item.added > 0 ? 4 : 0) }}
title={`${item.label}: ${item.added} added`}
title={t('products.activity.barAdded', {
month: item.label,
count: item.added,
})}
/>
<div
className={`${styles.bar} ${styles.barUpdated}`}
style={{ height: Math.max(updatedHeight, item.updated > 0 ? 4 : 0) }}
title={`${item.label}: ${item.updated} updated`}
title={t('products.activity.barUpdated', {
month: item.label,
count: item.updated,
})}
/>
</div>
<span className={styles.label}>{item.label}</span>
@@ -79,7 +79,7 @@
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
@@ -160,6 +160,12 @@
color: var(--primary);
}
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.controls button.danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
@@ -130,7 +130,7 @@
}
.optionFa {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-weight: 500;
direction: rtl;
}
@@ -56,28 +56,78 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
}
.link {
font-size: 14px;
font-weight: 500;
.countMeta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
height: 36px;
max-width: calc(100% - 48px);
padding: 0 14px;
box-sizing: border-box;
border-radius: 999px;
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
transition:
background 0.35s ease,
color 0.35s ease;
}
.countValue {
font-family: var(--font-en), var(--font-ui), sans-serif;
font-size: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1;
color: inherit;
}
.countLabel {
font-size: 12px;
font-weight: 500;
line-height: 1;
color: inherit;
opacity: 0.85;
}
.card:hover .countMeta {
background: var(--primary);
color: white;
}
.card:hover .countLabel {
opacity: 0.95;
}
.arrowBtn {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
transition: background 0.2s, transform 0.2s;
transition:
background 0.35s ease,
color 0.35s ease;
}
.arrowIcon {
display: block;
}
.card:hover .arrowBtn {
background: var(--primary);
color: white;
transform: translateX(2px);
}
:global([dir='rtl']) .arrowBtn {
transform: scaleX(-1);
}
+22 -4
View File
@@ -6,17 +6,26 @@ interface SectionCardProps {
icon: LucideIcon
title: string
description: string
linkText: string
linkText?: string
href: string
/** Entity total shown beside the arrow; omit for sections without a count (e.g. settings). */
count?: number | null
countLabel?: string
}
export function SectionCard({
icon: Icon,
title,
description,
linkText,
href,
count,
countLabel,
}: SectionCardProps) {
const showCount = typeof count === 'number' && Number.isFinite(count)
const formattedCount = showCount
? new Intl.NumberFormat('en-US').format(count)
: null
return (
<Link to={href} className={styles.card}>
<div className={styles.iconWrap}>
@@ -27,9 +36,18 @@ export function SectionCard({
<p className={styles.description}>{description}</p>
<div className={styles.footer}>
<span className={styles.link}>{linkText}</span>
{formattedCount !== null && countLabel ? (
<div className={styles.countMeta}>
<span className={styles.countValue} lang="en" dir="ltr">
{formattedCount}
</span>
<span className={styles.countLabel}>{countLabel}</span>
</div>
) : (
<span />
)}
<span className={styles.arrowBtn} aria-hidden="true">
<ArrowRight size={18} />
<ArrowRight size={18} className={styles.arrowIcon} />
</span>
</div>
</Link>
+15 -14
View File
@@ -1,7 +1,7 @@
.sidebar {
position: fixed;
top: 0;
left: 0;
inset-inline-start: 0;
width: var(--sidebar-width);
height: 100vh;
display: flex;
@@ -10,7 +10,7 @@
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
border-inline-end: 1px solid var(--glass-border);
z-index: 100;
}
@@ -45,26 +45,26 @@
}
.brandDomain {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.brandName {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
}
.brandName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
padding-right: 2px;
padding-inline-end: 2px;
}
.navGroup {
@@ -83,7 +83,7 @@
color: var(--text-secondary);
transition: all 0.2s ease;
width: 100%;
text-align: left;
text-align: start;
}
.navItem:hover {
@@ -118,9 +118,10 @@
display: flex;
flex-direction: column;
gap: 2px;
margin: 2px 0 4px 12px;
padding-left: 12px;
border-left: 2px solid rgba(148, 163, 184, 0.2);
margin-block: 2px 4px;
margin-inline-start: 12px;
padding-inline-start: 12px;
border-inline-start: 2px solid rgba(148, 163, 184, 0.2);
}
.subNavItem {
+147 -122
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import {
Home,
@@ -15,7 +15,10 @@ import {
Building2,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import {
BUSINESS_PROFILE_UPDATED_EVENT,
getActiveBusinessDomain,
@@ -26,111 +29,29 @@ import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
interface NavChild {
label: string
labelKey: BusinessMessageKey
to: string
}
interface NavGroup {
type: 'group'
id: string
icon: LucideIcon
label: string
labelKey: BusinessMessageKey
basePath: string
children: NavChild[]
}
interface NavLinkItem {
type: 'link'
id: string
icon: LucideIcon
label: string
labelKey: BusinessMessageKey
to: string
}
type NavItem = NavLinkItem | NavGroup
const navItems: NavItem[] = [
{ type: 'link', icon: Home, label: 'Home', to: '/' },
{ type: 'link', icon: Building2, label: 'Business Profile', to: '/business-profile' },
{
type: 'group',
icon: ShoppingBag,
label: 'Products',
basePath: '/products',
children: [
{ label: 'Overview', to: '/products' },
{ label: 'My Products', to: '/products/list' },
{ label: 'Add New Product', to: '/products/new' },
{ label: 'Categories', to: '/products/categories' },
{ label: 'Brands', to: '/products/brands' },
{ label: 'Settings', to: '/products/settings' },
],
},
{
type: 'group',
icon: Store,
label: 'Store',
basePath: '/store',
children: [
{ label: 'Overview', to: '/store' },
{ label: 'My Store Items', to: '/store/items' },
{ label: 'My Orders', to: '/store/orders' },
{ label: 'Shipping Fees', to: '/store/shipping' },
{ label: 'Shopping Cards', to: '/store/cards' },
{ label: 'Settings', to: '/store/settings' },
],
},
{ type: 'link', icon: Users, label: 'Customers', to: '/customers' },
{ type: 'link', icon: Settings, label: 'Settings', to: '/settings' },
{
type: 'group',
icon: FileText,
label: 'Blog',
basePath: '/blog',
children: [
{ label: 'Overview', to: '/blog' },
{ label: 'My Blogs', to: '/blog/list' },
{ label: 'Add New Blog', to: '/blog/new' },
{ label: 'Categories', to: '/blog/categories' },
{ label: 'Settings', to: '/blog/settings' },
],
},
{
type: 'group',
icon: Briefcase,
label: 'Portfolios',
basePath: '/portfolios',
children: [
{ label: 'Overview', to: '/portfolios' },
{ label: 'My Portfolios', to: '/portfolios/list' },
{ label: 'Add New Portfolio', to: '/portfolios/new' },
{ label: 'Categories', to: '/portfolios/categories' },
{ label: 'Settings', to: '/portfolios/settings' },
],
},
{
type: 'group',
icon: Globe,
label: 'Website',
basePath: '/website',
children: [
{ label: 'Overview', to: '/website' },
{ label: 'Sliders', to: '/website/sliders' },
{ label: 'Special Categories', to: '/website/special-categories' },
{ label: 'Special Brands', to: '/website/special-brands' },
{ label: 'Special Items', to: '/website/special-items' },
{ label: 'Contact Us Form', to: '/website/contact' },
{ label: 'Subscriptions', to: '/website/subscriptions' },
{ label: 'FAQ', to: '/website/faq' },
{ label: 'Badges', to: '/website/badges' },
{ label: 'E-Payment', to: '/website/e-payment' },
],
},
]
const footerItems = [
{ icon: HelpCircle, label: 'Help Center' },
{ icon: LogOut, label: 'Logout' },
]
function isGroupActive(basePath: string, pathname: string) {
return pathname === basePath || pathname.startsWith(`${basePath}/`)
}
@@ -139,12 +60,115 @@ export function Sidebar() {
const { pathname } = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const [brandLogoUrl, setBrandLogoUrl] = useState<string | null>(null)
const [brandName, setBrandName] = useState('')
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const businessDomain = getActiveBusinessDomain()
const fallbackBusinessName = user?.businesses[0]?.name ?? 'Business'
const fallbackBusinessName = user?.businesses[0]?.name ?? t('app.storeFallback')
const brandName = useMemo(() => {
if (locale === 'fa') {
return nameFa.trim() || nameEn.trim() || fallbackBusinessName
}
return nameEn.trim() || nameFa.trim() || fallbackBusinessName
}, [locale, nameEn, nameFa, fallbackBusinessName])
const navItems = useMemo<NavItem[]>(
() => [
{ type: 'link', id: 'home', icon: Home, labelKey: 'nav.home', to: '/' },
{
type: 'link',
id: 'business-profile',
icon: Building2,
labelKey: 'nav.businessProfile',
to: '/business-profile',
},
{
type: 'group',
id: 'products',
icon: ShoppingBag,
labelKey: 'nav.products',
basePath: '/products',
children: [
{ labelKey: 'nav.products.overview', to: '/products' },
{ labelKey: 'nav.products.list', to: '/products/list' },
{ labelKey: 'nav.products.new', to: '/products/new' },
{ labelKey: 'nav.products.categories', to: '/products/categories' },
{ labelKey: 'nav.products.brands', to: '/products/brands' },
{ labelKey: 'nav.products.settings', to: '/products/settings' },
],
},
{
type: 'group',
id: 'store',
icon: Store,
labelKey: 'nav.store',
basePath: '/store',
children: [
{ labelKey: 'nav.store.overview', to: '/store' },
{ labelKey: 'nav.store.items', to: '/store/items' },
{ labelKey: 'nav.store.orders', to: '/store/orders' },
{ labelKey: 'nav.store.shipping', to: '/store/shipping' },
{ labelKey: 'nav.store.cards', to: '/store/cards' },
{ labelKey: 'nav.store.settings', to: '/store/settings' },
],
},
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
{
type: 'group',
id: 'blog',
icon: FileText,
labelKey: 'nav.blog',
basePath: '/blog',
children: [
{ labelKey: 'nav.blog.overview', to: '/blog' },
{ labelKey: 'nav.blog.list', to: '/blog/list' },
{ labelKey: 'nav.blog.new', to: '/blog/new' },
{ labelKey: 'nav.blog.categories', to: '/blog/categories' },
{ labelKey: 'nav.blog.settings', to: '/blog/settings' },
],
},
{
type: 'group',
id: 'portfolios',
icon: Briefcase,
labelKey: 'nav.portfolios',
basePath: '/portfolios',
children: [
{ labelKey: 'nav.portfolios.overview', to: '/portfolios' },
{ labelKey: 'nav.portfolios.list', to: '/portfolios/list' },
{ labelKey: 'nav.portfolios.new', to: '/portfolios/new' },
{ labelKey: 'nav.portfolios.categories', to: '/portfolios/categories' },
{ labelKey: 'nav.portfolios.settings', to: '/portfolios/settings' },
],
},
{
type: 'group',
id: 'website',
icon: Globe,
labelKey: 'nav.website',
basePath: '/website',
children: [
{ labelKey: 'nav.website.overview', to: '/website' },
{ labelKey: 'nav.website.sliders', to: '/website/sliders' },
{ labelKey: 'nav.website.specialCategories', to: '/website/special-categories' },
{ labelKey: 'nav.website.specialBrands', to: '/website/special-brands' },
{ labelKey: 'nav.website.specialItems', to: '/website/special-items' },
{ labelKey: 'nav.website.contact', to: '/website/contact' },
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
{ labelKey: 'nav.website.faq', to: '/website/faq' },
{ labelKey: 'nav.website.badges', to: '/website/badges' },
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
],
},
],
[],
)
useEffect(() => {
const controller = new AbortController()
@@ -153,11 +177,13 @@ export function Sidebar() {
try {
const data = await getBusinessProfile(controller.signal)
setBrandLogoUrl(data.profile.logoUrl)
setBrandName(data.profile.nameEn.trim() || data.profile.nameFa.trim() || fallbackBusinessName)
setNameEn(data.profile.nameEn.trim())
setNameFa(data.profile.nameFa.trim())
} catch (err) {
if (isAbortError(err)) return
setBrandLogoUrl(null)
setBrandName(fallbackBusinessName)
setNameEn('')
setNameFa('')
}
}
@@ -172,18 +198,18 @@ export function Sidebar() {
controller.abort()
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
}
}, [fallbackBusinessName])
}, [])
useEffect(() => {
navItems.forEach((item) => {
if (item.type === 'group' && isGroupActive(item.basePath, pathname)) {
setOpenGroups((prev) => ({ ...prev, [item.label]: true }))
setOpenGroups((prev) => ({ ...prev, [item.id]: true }))
}
})
}, [pathname])
}, [pathname, navItems])
function toggleGroup(label: string) {
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }))
function toggleGroup(id: string) {
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] }))
}
return (
@@ -191,12 +217,12 @@ export function Sidebar() {
<div className={styles.brand}>
<img
src={brandLogoUrl ?? meshkeeLogo}
alt={brandName || 'Business logo'}
alt={brandName || t('app.storeFallback')}
className={`${styles.brandLogo} ${brandLogoUrl ? styles.brandLogoUploaded : ''}`}
/>
<div className={styles.brandText}>
<span className={styles.brandDomain}>{businessDomain}</span>
<span className={styles.brandName}>{brandName || fallbackBusinessName}</span>
<span className={styles.brandDomain}>{businessDomain}</span>
</div>
</div>
@@ -205,7 +231,7 @@ export function Sidebar() {
if (item.type === 'link') {
return (
<NavLink
key={item.label}
key={item.id}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
@@ -213,24 +239,24 @@ export function Sidebar() {
}
>
<item.icon size={20} />
<span>{item.label}</span>
<span>{t(item.labelKey)}</span>
</NavLink>
)
}
const isOpen = openGroups[item.label] ?? false
const isOpen = openGroups[item.id] ?? false
const groupActive = isGroupActive(item.basePath, pathname)
return (
<div key={item.label} className={styles.navGroup}>
<div key={item.id} className={styles.navGroup}>
<button
type="button"
className={`${styles.navItem} ${styles.navGroupBtn} ${groupActive ? styles.active : ''}`}
onClick={() => toggleGroup(item.label)}
onClick={() => toggleGroup(item.id)}
aria-expanded={isOpen}
>
<item.icon size={20} />
<span className={styles.navGroupLabel}>{item.label}</span>
<span className={styles.navGroupLabel}>{t(item.labelKey)}</span>
<ChevronDown
size={16}
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
@@ -248,7 +274,7 @@ export function Sidebar() {
`${styles.subNavItem} ${isActive ? styles.subNavActive : ''}`
}
>
{child.label}
{t(child.labelKey)}
</NavLink>
))}
</div>
@@ -259,22 +285,21 @@ export function Sidebar() {
</nav>
<div className={styles.footer}>
{footerItems.map(({ icon: Icon, label }) => (
<button
key={label}
type="button"
className={styles.navItem}
onClick={() => {
if (label === 'Logout') {
logout()
navigate('/login')
}
}}
>
<Icon size={20} />
<span>{label}</span>
</button>
))}
<button type="button" className={styles.navItem}>
<HelpCircle size={20} />
<span>{t('nav.help')}</span>
</button>
<button
type="button"
className={styles.navItem}
onClick={() => {
logout()
navigate('/login')
}}
>
<LogOut size={20} />
<span>{t('nav.logout')}</span>
</button>
</div>
</aside>
)
@@ -101,7 +101,7 @@
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
@@ -31,7 +31,7 @@
.nameFa {
margin-top: 4px;
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
@@ -4,10 +4,12 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { applyDocumentFavicon } from '@meshkee/dashboard-core'
import { useLocale } from '@meshkee/dashboard-ui'
import { isAbortError } from '../lib/api'
import { getBusinessDomain } from '../lib/config'
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
@@ -34,10 +36,13 @@ function pickBusinessName(
}
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const [businessName, setBusinessName] = useState('')
const { locale, setLocale } = useLocale()
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const [refreshToken, setRefreshToken] = useState(0)
const defaultLocaleAppliedRef = useRef(false)
const refreshBranding = useCallback(() => {
setRefreshToken((value) => value + 1)
@@ -52,7 +57,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const tenant = await resolveTenantByDomain(domain)
if (controller.signal.aborted) return
let name = pickBusinessName(tenant.name, tenant.nameFa, domain)
let nextNameEn = pickBusinessName(tenant.name, domain)
let nextNameFa = pickBusinessName(tenant.nameFa, tenant.name, domain)
let nextLogo = tenant.logoUrl?.trim() || null
let nextFavicon =
tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null
@@ -60,10 +66,15 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
try {
const profile = await getBusinessProfile(controller.signal)
if (!controller.signal.aborted) {
name = pickBusinessName(
nextNameEn = pickBusinessName(
profile.profile.nameEn,
nextNameEn,
domain,
)
nextNameFa = pickBusinessName(
profile.profile.nameFa,
name,
profile.profile.nameEn,
nextNameFa,
domain,
)
nextLogo = profile.profile.logoUrl?.trim() || nextLogo
@@ -79,14 +90,25 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
}
if (!controller.signal.aborted) {
setBusinessName(name || domain)
setNameEn(nextNameEn || domain)
setNameFa(nextNameFa || nextNameEn || domain)
setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon)
if (!defaultLocaleAppliedRef.current) {
defaultLocaleAppliedRef.current = true
if (tenant.defaultLocale === 'en' || tenant.defaultLocale === 'fa') {
setLocale(tenant.defaultLocale)
} else {
setLocale('fa')
}
}
}
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain)
const domain = getBusinessDomain()
setNameEn(domain)
setNameFa(domain)
setLogoUrl(null)
setFaviconUrl(null)
applyDocumentFavicon(null)
@@ -104,7 +126,14 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
controller.abort()
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
}
}, [refreshToken, refreshBranding])
}, [refreshToken, refreshBranding, setLocale])
const businessName = useMemo(() => {
if (locale === 'fa') {
return nameFa.trim() || nameEn.trim() || getBusinessDomain()
}
return nameEn.trim() || nameFa.trim() || getBusinessDomain()
}, [locale, nameEn, nameFa])
const value = useMemo(
() => ({ businessName, logoUrl, faviconUrl, refreshBranding }),
+621
View File
@@ -0,0 +1,621 @@
import type { DashboardLocale, RouteTitleRule } from '@meshkee/dashboard-core'
const en = {
'app.dashboardName': 'Business Dashboard',
'app.storeFallback': 'Business',
'app.poweredBy': 'powered by Meshkee.app',
'app.userFallback': 'User',
'role.superAdmin': 'Super Admin',
'role.owner': 'Business Owner',
'role.staff': 'Staff',
'nav.home': 'Home',
'nav.businessProfile': 'Business Profile',
'nav.products': 'Products',
'nav.products.overview': 'Overview',
'nav.products.list': 'My Products',
'nav.products.new': 'Add New Product',
'nav.products.categories': 'Categories',
'nav.products.brands': 'Brands',
'nav.products.settings': 'Settings',
'nav.store': 'Store',
'nav.store.overview': 'Overview',
'nav.store.items': 'My Store Items',
'nav.store.orders': 'My Orders',
'nav.store.shipping': 'Shipping Fees',
'nav.store.cards': 'Shopping Cards',
'nav.store.settings': 'Settings',
'nav.customers': 'Customers',
'nav.settings': 'Settings',
'nav.blog': 'Blog',
'nav.blog.overview': 'Overview',
'nav.blog.list': 'My Blogs',
'nav.blog.new': 'Add New Blog',
'nav.blog.categories': 'Categories',
'nav.blog.settings': 'Settings',
'nav.portfolios': 'Portfolios',
'nav.portfolios.overview': 'Overview',
'nav.portfolios.list': 'My Portfolios',
'nav.portfolios.new': 'Add New Portfolio',
'nav.portfolios.categories': 'Categories',
'nav.portfolios.settings': 'Settings',
'nav.website': 'Website',
'nav.website.overview': 'Overview',
'nav.website.sliders': 'Sliders',
'nav.website.specialCategories': 'Special Categories',
'nav.website.specialBrands': 'Special Brands',
'nav.website.specialItems': 'Special Items',
'nav.website.contact': 'Contact Us Form',
'nav.website.subscriptions': 'Subscriptions',
'nav.website.faq': 'FAQ',
'nav.website.badges': 'Badges',
'nav.website.ePayment': 'E-Payment',
'nav.help': 'Help Center',
'nav.logout': 'Logout',
'header.toggleMenu': 'Toggle menu',
'header.title': 'Admin Dashboard',
'header.messages': 'Messages',
'header.notifications': 'Notifications',
'header.profile': 'Profile',
'header.setting': 'Setting',
'header.changePassword': 'Change password',
'bc.dashboard': 'Dashboard',
'bc.editProduct': 'Edit Product',
'bc.productDetails': 'Product Details',
'bc.editBlog': 'Edit Blog',
'bc.blogDetails': 'Blog Details',
'bc.editPortfolio': 'Edit Portfolio',
'bc.portfolioDetails': 'Portfolio Details',
'home.welcome': 'Welcome back, {name}!',
'home.welcomeFallback': 'there',
'home.subtitle': "Here's what's happening with your store today.",
'home.card.products.title': 'Products',
'home.card.products.desc': 'Manage your products, inventory and categories.',
'home.card.products.link': 'View products',
'home.card.products.count': 'products',
'home.card.store.title': 'Store',
'home.card.store.desc': 'Manage your store settings, pages and themes.',
'home.card.store.link': 'View store',
'home.card.store.count': 'on sale',
'home.card.customers.title': 'Customers',
'home.card.customers.desc': 'View and manage your customers and their activity.',
'home.card.customers.link': 'View customers',
'home.card.customers.count': 'people',
'home.card.settings.title': 'Settings',
'home.card.settings.desc': 'Configure your store preferences and system settings.',
'home.card.settings.link': 'View settings',
'home.card.blog.title': 'Blog',
'home.card.blog.desc': 'Create and manage blog posts and categories.',
'home.card.blog.link': 'View blog',
'home.card.blog.count': 'posts',
'home.card.portfolios.title': 'Portfolios',
'home.card.portfolios.desc': 'Manage your portfolio items and showcase projects.',
'home.card.portfolios.link': 'View portfolios',
'home.card.portfolios.count': 'portfolios',
'home.card.website.title': 'Website',
'home.card.website.desc': 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
'home.card.website.link': 'View website',
'home.chart.orders.title': 'Orders',
'home.chart.orders.subtitle': 'Orders and cart adds in the last 30 days',
'home.chart.orders.legend': 'Orders ({count})',
'home.chart.orders.cartLegend': 'Added to basket ({count})',
'home.chart.orders.loading': 'Loading chart...',
'home.chart.orders.error': 'Unable to load order activity.',
'home.chart.orders.bar': '{day}: {count} orders',
'home.chart.orders.cartBar': '{day}: {count} added to basket',
'home.chart.customers.title': 'Customers',
'home.chart.customers.subtitle': 'Registrations and active users in the last 30 days',
'home.chart.customers.legend': 'Registered ({count})',
'home.chart.customers.activeLegend': 'Active ({count})',
'home.chart.customers.loading': 'Loading chart...',
'home.chart.customers.error': 'Unable to load customer activity.',
'home.chart.customers.bar': '{day}: {count} registered',
'home.chart.customers.activeBar': '{day}: {count} active',
'products.overview.subtitle': 'Manage your products, inventory and categories.',
'products.card.list.desc': 'View, edit and manage all your existing products.',
'products.card.new.title': 'Add a New Product',
'products.card.new.desc': 'Create and publish a new product to your store.',
'products.form.edit.subtitle': 'Update product details and save changes.',
'products.card.categories.desc': 'Organize your products into categories and subcategories.',
'products.card.brands.desc': 'Manage product brands and assign them when creating products.',
'products.card.settings.desc': 'Configure product defaults, variants and display options.',
'products.activity.title': 'Product activity',
'products.activity.subtitle': 'Products added or updated in the last 12 months',
'products.activity.added': 'Added ({count})',
'products.activity.updated': 'Updated ({count})',
'products.activity.loading': 'Loading chart...',
'products.activity.error': 'Unable to load product activity.',
'products.activity.chartAria': 'Bar chart of products added and updated per month',
'products.activity.barAdded': '{month}: {count} added',
'products.activity.barUpdated': '{month}: {count} updated',
'title.signIn': 'Sign in',
'title.home': 'Home',
'title.businessProfile': 'Business Profile',
'title.products': 'Products',
'title.myProducts': 'My Products',
'title.addProduct': 'Add New Product',
'title.editProduct': 'Edit Product',
'title.productDetails': 'Product Details',
'title.categories': 'Categories',
'title.brands': 'Brands',
'title.settings': 'Settings',
'title.store': 'Store',
'title.storeItems': 'My Store Items',
'title.orders': 'My Orders',
'title.shoppingCards': 'Shopping Cards',
'title.customers': 'Customers',
'title.blog': 'Blog',
'title.myBlogs': 'My Blogs',
'title.addBlog': 'Add New Blog',
'title.editBlog': 'Edit Blog',
'title.blogDetails': 'Blog Details',
'title.portfolios': 'Portfolios',
'title.myPortfolios': 'My Portfolios',
'title.addPortfolio': 'Add New Portfolio',
'title.editPortfolio': 'Edit Portfolio',
'title.portfolioDetails': 'Portfolio Details',
'title.website': 'Website',
'title.sliders': 'Sliders',
'title.specialCategories': 'Special Categories',
'title.specialBrands': 'Special Brands',
'title.specialItems': 'Special Items',
'title.contactForm': 'Contact Us Form',
'title.subscriptions': 'Subscriptions',
'title.faq': 'FAQ',
'title.badges': 'Badges',
'title.ePayment': 'E-Payment',
'login.welcome': 'Welcome back',
'login.subtitle': 'Sign in with your mobile number',
'login.mobile': 'Mobile number',
'login.password': 'Password',
'login.passwordPlaceholder': 'Enter your password',
'login.hidePassword': 'Hide password',
'login.showPassword': 'Show password',
'login.forgot': 'Forgot password?',
'login.signIn': 'Sign in',
'login.signingIn': 'Signing in...',
'login.or': 'or',
'login.otp': 'One-time login with SMS',
'login.noAccount': "Don't have an account?",
'login.signUp': 'Sign up',
'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
'login.error.sendCode': 'Unable to send verification code.',
'login.error.access': 'You do not have access to this business dashboard.',
'signup.title': 'Create account',
'signup.subtitle': 'Register for {domain}',
'signup.firstName': 'First name',
'signup.lastName': 'Last name',
'signup.passwordPlaceholder': 'Choose a password',
'signup.confirm': 'Confirm password',
'signup.confirmPlaceholder': 'Repeat your password',
'signup.create': 'Create account',
'signup.creating': 'Creating account...',
'signup.hasAccount': 'Already have an account?',
'signup.signIn': 'Sign in',
'signup.error.match': 'Passwords do not match.',
'signup.error.length': 'Password must be at least 8 characters.',
'signup.error.create': 'Unable to create account.',
'forgot.back': 'Back to sign in',
'forgot.title': 'Forgot password',
'forgot.subtitlePhone': 'We will send a verification code via SMS',
'forgot.subtitleCode': 'Enter the code and your new password',
'forgot.sendCode': 'Send SMS code',
'forgot.sending': 'Sending...',
'forgot.code': 'SMS verification code',
'forgot.newPassword': 'New password',
'forgot.newPasswordPlaceholder': 'Enter new password',
'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.verify': 'Unable to verify code.',
'forgot.info.partial':
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
'otp.back': 'Back to sign in',
'otp.title': 'One-time login',
'otp.subtitlePhone': 'Verify your mobile number with a one-time SMS code',
'otp.subtitleCode': 'Enter the SMS code and your password',
'otp.sendCode': 'Send SMS code',
'otp.sending': 'Sending...',
'otp.code': 'SMS verification code',
'otp.password': 'Password',
'otp.passwordPlaceholder': 'Your account password',
'otp.signIn': 'Sign in',
'otp.signingIn': 'Signing in...',
'otp.error.password': 'Enter your account password to complete sign-in after SMS verification.',
'otp.error.signIn': 'Unable to sign in with SMS verification.',
'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s',
'common.resend': 'Resend SMS code',
'common.codeSent': 'Verification code sent to {phone}',
'common.breadcrumb': 'Breadcrumb',
'common.overview': 'Overview',
} as const
type MessageKey = keyof typeof en
const fa: Record<MessageKey, string> = {
'app.dashboardName': 'پنل کسب‌وکار',
'app.storeFallback': 'کسب‌وکار',
'app.poweredBy': 'قدرت‌گرفته از Meshkee.app',
'app.userFallback': 'کاربر',
'role.superAdmin': 'سوپرادمین',
'role.owner': 'صاحب کسب‌وکار',
'role.staff': 'کارمند',
'nav.home': 'خانه',
'nav.businessProfile': 'پروفایل کسب‌وکار',
'nav.products': 'محصولات',
'nav.products.overview': 'نمای کلی',
'nav.products.list': 'محصولات من',
'nav.products.new': 'افزودن محصول',
'nav.products.categories': 'دسته‌بندی‌ها',
'nav.products.brands': 'برندها',
'nav.products.settings': 'تنظیمات',
'nav.store': 'فروشگاه',
'nav.store.overview': 'نمای کلی',
'nav.store.items': 'اقلام فروشگاه',
'nav.store.orders': 'سفارش‌های من',
'nav.store.shipping': 'هزینه ارسال',
'nav.store.cards': 'کارت‌های خرید',
'nav.store.settings': 'تنظیمات',
'nav.customers': 'مشتریان',
'nav.settings': 'تنظیمات',
'nav.blog': 'بلاگ',
'nav.blog.overview': 'نمای کلی',
'nav.blog.list': 'بلاگ‌های من',
'nav.blog.new': 'افزودن بلاگ',
'nav.blog.categories': 'دسته‌بندی‌ها',
'nav.blog.settings': 'تنظیمات',
'nav.portfolios': 'نمونه کارها',
'nav.portfolios.overview': 'نمای کلی',
'nav.portfolios.list': 'نمونه کارهای من',
'nav.portfolios.new': 'افزودن نمونه کار',
'nav.portfolios.categories': 'دسته‌بندی‌ها',
'nav.portfolios.settings': 'تنظیمات',
'nav.website': 'وب‌سایت',
'nav.website.overview': 'نمای کلی',
'nav.website.sliders': 'اسلایدرها',
'nav.website.specialCategories': 'دسته‌های ویژه',
'nav.website.specialBrands': 'برندهای ویژه',
'nav.website.specialItems': 'اقلام ویژه',
'nav.website.contact': 'فرم تماس با ما',
'nav.website.subscriptions': 'عضویت‌ها',
'nav.website.faq': 'سوالات متداول',
'nav.website.badges': 'نشان‌ها',
'nav.website.ePayment': 'پرداخت الکترونیک',
'nav.help': 'مرکز راهنما',
'nav.logout': 'خروج',
'header.toggleMenu': 'باز و بسته کردن منو',
'header.title': 'پنل مدیریت',
'header.messages': 'پیام‌ها',
'header.notifications': 'اعلان‌ها',
'header.profile': 'پروفایل',
'header.setting': 'تنظیمات',
'header.changePassword': 'تغییر رمز عبور',
'bc.dashboard': 'داشبورد',
'bc.editProduct': 'ویرایش محصول',
'bc.productDetails': 'جزئیات محصول',
'bc.editBlog': 'ویرایش بلاگ',
'bc.blogDetails': 'جزئیات بلاگ',
'bc.editPortfolio': 'ویرایش نمونه کار',
'bc.portfolioDetails': 'جزئیات نمونه کار',
'home.welcome': '{name} عزیز، خوش آمدی.',
'home.welcomeFallback': 'کاربر',
'home.subtitle': 'وضعیت فروشگاهتان را از اینجا دنبال کنید.',
'home.card.products.title': 'محصولات',
'home.card.products.desc': 'محصولات، موجودی و دسته‌بندی‌ها را مدیریت کنید.',
'home.card.products.link': 'مشاهده محصولات',
'home.card.products.count': 'محصول',
'home.card.store.title': 'فروشگاه',
'home.card.store.desc': 'تنظیمات، صفحات و ظاهر فروشگاه را مدیریت کنید.',
'home.card.store.link': 'مشاهده فروشگاه',
'home.card.store.count': 'محصول فروش',
'home.card.customers.title': 'مشتریان',
'home.card.customers.desc': 'مشتریان و فعالیت آن‌ها را ببینید و مدیریت کنید.',
'home.card.customers.link': 'مشاهده مشتریان',
'home.card.customers.count': 'نفر',
'home.card.settings.title': 'تنظیمات',
'home.card.settings.desc': 'ترجیحات فروشگاه و تنظیمات سیستم را پیکربندی کنید.',
'home.card.settings.link': 'مشاهده تنظیمات',
'home.card.blog.title': 'بلاگ',
'home.card.blog.desc': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.',
'home.card.blog.link': 'مشاهده بلاگ',
'home.card.blog.count': 'مطلب',
'home.card.portfolios.title': 'نمونه کارها',
'home.card.portfolios.desc': 'نمونه کارها و پروژه‌های نمایشی را مدیریت کنید.',
'home.card.portfolios.link': 'مشاهده نمونه کارها',
'home.card.portfolios.count': 'نمونه کار',
'home.card.website.title': 'وب‌سایت',
'home.card.website.desc': 'فرم تماس، سوالات متداول، نشان‌ها، عضویت‌ها و پرداخت الکترونیک.',
'home.card.website.link': 'مشاهده وب‌سایت',
'home.chart.orders.title': 'سفارش‌ها',
'home.chart.orders.subtitle': 'سفارش‌ها و افزودن به سبد در ۳۰ روز گذشته',
'home.chart.orders.legend': 'سفارش ({count})',
'home.chart.orders.cartLegend': 'افزودن به سبد ({count})',
'home.chart.orders.loading': 'در حال بارگذاری نمودار...',
'home.chart.orders.error': 'بارگذاری فعالیت سفارش‌ها ممکن نشد.',
'home.chart.orders.bar': '{day}: {count} سفارش',
'home.chart.orders.cartBar': '{day}: {count} افزودن به سبد',
'home.chart.customers.title': 'مشتریان',
'home.chart.customers.subtitle': 'ثبت‌نام و کاربران فعال در ۳۰ روز گذشته',
'home.chart.customers.legend': 'ثبت‌نام ({count})',
'home.chart.customers.activeLegend': 'فعال ({count})',
'home.chart.customers.loading': 'در حال بارگذاری نمودار...',
'home.chart.customers.error': 'بارگذاری فعالیت مشتریان ممکن نشد.',
'home.chart.customers.bar': '{day}: {count} ثبت‌نام',
'home.chart.customers.activeBar': '{day}: {count} فعال',
'products.overview.subtitle': 'محصولات، موجودی و دسته‌بندی‌ها را مدیریت کنید.',
'products.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.',
'products.card.new.title': 'افزودن محصول جدید',
'products.card.new.desc': 'یک محصول جدید بسازید و در فروشگاه منتشر کنید.',
'products.form.edit.subtitle': 'جزئیات محصول را به‌روز کنید و ذخیره کنید.',
'products.card.categories.desc': 'محصولات را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.',
'products.card.brands.desc': 'برندهای محصول را مدیریت کنید و هنگام ساخت محصول به آن‌ها اختصاص دهید.',
'products.card.settings.desc': 'پیش‌فرض‌ها، تنوع‌ها و گزینه‌های نمایش محصول را پیکربندی کنید.',
'products.activity.title': 'فعالیت محصولات',
'products.activity.subtitle': 'محصولات افزوده‌شده یا به‌روزرسانی‌شده در ۱۲ ماه گذشته',
'products.activity.added': 'افزوده‌شده ({count})',
'products.activity.updated': 'به‌روزرسانی‌شده ({count})',
'products.activity.loading': 'در حال بارگذاری نمودار...',
'products.activity.error': 'بارگذاری فعالیت محصولات ممکن نشد.',
'products.activity.chartAria': 'نمودار میله‌ای محصولات افزوده‌شده و به‌روزرسانی‌شده در هر ماه',
'products.activity.barAdded': '{month}: {count} افزوده‌شده',
'products.activity.barUpdated': '{month}: {count} به‌روزرسانی‌شده',
'title.signIn': 'ورود',
'title.home': 'خانه',
'title.businessProfile': 'پروفایل کسب‌وکار',
'title.products': 'محصولات',
'title.myProducts': 'محصولات من',
'title.addProduct': 'افزودن محصول',
'title.editProduct': 'ویرایش محصول',
'title.productDetails': 'جزئیات محصول',
'title.categories': 'دسته‌بندی‌ها',
'title.brands': 'برندها',
'title.settings': 'تنظیمات',
'title.store': 'فروشگاه',
'title.storeItems': 'اقلام فروشگاه',
'title.orders': 'سفارش‌های من',
'title.shoppingCards': 'کارت‌های خرید',
'title.customers': 'مشتریان',
'title.blog': 'بلاگ',
'title.myBlogs': 'بلاگ‌های من',
'title.addBlog': 'افزودن بلاگ',
'title.editBlog': 'ویرایش بلاگ',
'title.blogDetails': 'جزئیات بلاگ',
'title.portfolios': 'نمونه کارها',
'title.myPortfolios': 'نمونه کارهای من',
'title.addPortfolio': 'افزودن نمونه کار',
'title.editPortfolio': 'ویرایش نمونه کار',
'title.portfolioDetails': 'جزئیات نمونه کار',
'title.website': 'وب‌سایت',
'title.sliders': 'اسلایدرها',
'title.specialCategories': 'دسته‌های ویژه',
'title.specialBrands': 'برندهای ویژه',
'title.specialItems': 'اقلام ویژه',
'title.contactForm': 'فرم تماس با ما',
'title.subscriptions': 'عضویت‌ها',
'title.faq': 'سوالات متداول',
'title.badges': 'نشان‌ها',
'title.ePayment': 'پرداخت الکترونیک',
'login.welcome': 'خوش آمدید',
'login.subtitle': 'با شماره موبایل وارد شوید',
'login.mobile': 'شماره موبایل',
'login.password': 'رمز عبور',
'login.passwordPlaceholder': 'رمز عبور را وارد کنید',
'login.hidePassword': 'مخفی کردن رمز',
'login.showPassword': 'نمایش رمز',
'login.forgot': 'رمز عبور را فراموش کرده‌اید؟',
'login.signIn': 'ورود',
'login.signingIn': 'در حال ورود...',
'login.or': 'یا',
'login.otp': 'ورود یک‌بارمصرف با پیامک',
'login.noAccount': 'حساب ندارید؟',
'login.signUp': 'ثبت‌نام',
'login.error.signIn': 'ورود ممکن نشد. اتصال را بررسی کنید و دوباره تلاش کنید.',
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
'login.error.access': 'به این پنل کسب‌وکار دسترسی ندارید.',
'signup.title': 'ایجاد حساب',
'signup.subtitle': 'ثبت‌نام برای {domain}',
'signup.firstName': 'نام',
'signup.lastName': 'نام خانوادگی',
'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید',
'signup.confirm': 'تأیید رمز عبور',
'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید',
'signup.create': 'ایجاد حساب',
'signup.creating': 'در حال ایجاد حساب...',
'signup.hasAccount': 'قبلاً حساب دارید؟',
'signup.signIn': 'ورود',
'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.',
'forgot.back': 'بازگشت به ورود',
'forgot.title': 'فراموشی رمز عبور',
'forgot.subtitlePhone': 'کد تأیید را با پیامک ارسال می‌کنیم',
'forgot.subtitleCode': 'کد و رمز عبور جدید را وارد کنید',
'forgot.sendCode': 'ارسال کد پیامکی',
'forgot.sending': 'در حال ارسال...',
'forgot.code': 'کد تأیید پیامکی',
'forgot.newPassword': 'رمز عبور جدید',
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
'forgot.reset': 'بازنشانی رمز عبور',
'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.verify': 'تأیید کد ممکن نشد.',
'forgot.info.partial':
'شماره تأیید شد. بازنشانی کامل رمز با پیامک هنوز فعال نیست — با پشتیبانی تماس بگیرید یا اگر رمز را به یاد دارید وارد شوید.',
'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف',
'otp.subtitlePhone': 'شماره موبایل را با کد پیامکی یک‌بارمصرف تأیید کنید',
'otp.subtitleCode': 'کد پیامکی و رمز عبور را وارد کنید',
'otp.sendCode': 'ارسال کد پیامکی',
'otp.sending': 'در حال ارسال...',
'otp.code': 'کد تأیید پیامکی',
'otp.password': 'رمز عبور',
'otp.passwordPlaceholder': 'رمز عبور حساب',
'otp.signIn': 'ورود',
'otp.signingIn': 'در حال ورود...',
'otp.error.password': 'برای تکمیل ورود پس از تأیید پیامک، رمز عبور حساب را وارد کنید.',
'otp.error.signIn': 'ورود با تأیید پیامکی ممکن نشد.',
'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
'common.resend': 'ارسال مجدد کد پیامکی',
'common.codeSent': 'کد تأیید به {phone} ارسال شد',
'common.breadcrumb': 'مسیر صفحه',
'common.overview': 'نمای کلی',
}
const dictionaries: Record<DashboardLocale, Record<MessageKey, string>> = {
en: en as Record<MessageKey, string>,
fa,
}
export type BusinessMessageKey = MessageKey
/** Maps hardcoded English breadcrumb labels used across pages → message keys. */
const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
Home: 'nav.home',
Dashboard: 'bc.dashboard',
'Business Profile': 'nav.businessProfile',
Products: 'nav.products',
Overview: 'common.overview',
'My Products': 'nav.products.list',
'Add New Product': 'nav.products.new',
'Add a New Product': 'nav.products.new',
'Edit Product': 'bc.editProduct',
'Product Details': 'bc.productDetails',
Categories: 'title.categories',
Brands: 'title.brands',
Settings: 'title.settings',
Store: 'nav.store',
'My Store Items': 'nav.store.items',
'My Orders': 'nav.store.orders',
'Shipping Fees': 'nav.store.shipping',
'Shopping Cards': 'nav.store.cards',
Customers: 'nav.customers',
Blog: 'nav.blog',
'My Blogs': 'nav.blog.list',
'Add New Blog': 'nav.blog.new',
'Edit Blog': 'bc.editBlog',
'Blog Details': 'bc.blogDetails',
Portfolios: 'nav.portfolios',
'My Portfolios': 'nav.portfolios.list',
'Add New Portfolio': 'nav.portfolios.new',
'Edit Portfolio': 'bc.editPortfolio',
'Portfolio Details': 'bc.portfolioDetails',
Website: 'nav.website',
Sliders: 'nav.website.sliders',
'Special Categories': 'nav.website.specialCategories',
'Special Brands': 'nav.website.specialBrands',
'Special Items': 'nav.website.specialItems',
'Contact Us Form': 'nav.website.contact',
Subscriptions: 'nav.website.subscriptions',
FAQ: 'nav.website.faq',
Badges: 'nav.website.badges',
'E-Payment': 'nav.website.ePayment',
Orders: 'nav.store.orders',
Profile: 'header.profile',
'Sign in': 'title.signIn',
}
export function translate(
locale: DashboardLocale,
key: MessageKey,
vars?: Record<string, string | number>,
): string {
const dict = dictionaries[locale] ?? dictionaries.en
let text = dict[key] ?? dictionaries.en[key] ?? key
if (vars) {
for (const [name, value] of Object.entries(vars)) {
text = text.replaceAll(`{${name}}`, String(value))
}
}
return text
}
export function translateBreadcrumbLabel(locale: DashboardLocale, label: string): string {
const key = BREADCRUMB_LABEL_KEYS[label]
return key ? translate(locale, key) : label
}
export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleRule[] {
const t = (key: MessageKey) => translate(locale, key)
return [
{ match: '/login', labels: [t('title.signIn')] },
{ match: '/business-profile', labels: [t('title.businessProfile')] },
{ match: '/products/categories', labels: [t('title.products'), t('title.categories')] },
{ match: '/products/brands', labels: [t('title.products'), t('title.brands')] },
{ match: '/products/new', labels: [t('title.products'), t('title.addProduct')] },
{ match: /^\/products\/edit\/[^/]+$/, labels: [t('title.products'), t('title.editProduct')] },
{ match: '/products/list', labels: [t('title.products'), t('title.myProducts')] },
{
match: /^\/products\/detail\/[^/]+$/,
labels: [t('title.products'), t('title.productDetails')],
},
{ match: '/products/settings', labels: [t('title.products'), t('title.settings')] },
{ match: '/products', labels: [t('title.products')] },
{ match: '/store/items', labels: [t('title.store'), t('title.storeItems')] },
{ match: '/store/orders', labels: [t('title.store'), t('title.orders')] },
{ match: '/store/cards', labels: [t('title.store'), t('title.shoppingCards')] },
{ match: '/store/settings', labels: [t('title.store'), t('title.settings')] },
{ match: '/store', labels: [t('title.store')] },
{ match: '/customers', labels: [t('title.customers')] },
{ match: '/blog/list', labels: [t('title.blog'), t('title.myBlogs')] },
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] },
{ match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] },
{ match: /^\/blog\/edit\/[^/]+$/, labels: [t('title.blog'), t('title.editBlog')] },
{ match: '/blog/categories', labels: [t('title.blog'), t('title.categories')] },
{ match: '/blog/settings', labels: [t('title.blog'), t('title.settings')] },
{ match: '/blog', labels: [t('title.blog')] },
{ match: '/portfolios/list', labels: [t('title.portfolios'), t('title.myPortfolios')] },
{
match: /^\/portfolios\/detail\/[^/]+$/,
labels: [t('title.portfolios'), t('title.portfolioDetails')],
},
{ match: '/portfolios/new', labels: [t('title.portfolios'), t('title.addPortfolio')] },
{
match: /^\/portfolios\/edit\/[^/]+$/,
labels: [t('title.portfolios'), t('title.editPortfolio')],
},
{ match: '/portfolios/categories', labels: [t('title.portfolios'), t('title.categories')] },
{ match: '/portfolios/settings', labels: [t('title.portfolios'), t('title.settings')] },
{ match: '/portfolios', labels: [t('title.portfolios')] },
{ match: '/website/sliders', labels: [t('title.website'), t('title.sliders')] },
{
match: '/website/special-categories',
labels: [t('title.website'), t('title.specialCategories')],
},
{ match: '/website/special-brands', labels: [t('title.website'), t('title.specialBrands')] },
{ match: '/website/special-items', labels: [t('title.website'), t('title.specialItems')] },
{ match: '/website/contact', labels: [t('title.website'), t('title.contactForm')] },
{ match: '/website/subscriptions', labels: [t('title.website'), t('title.subscriptions')] },
{ match: '/website/faq', labels: [t('title.website'), t('title.faq')] },
{ match: '/website/badges', labels: [t('title.website'), t('title.badges')] },
{ match: '/website/e-payment', labels: [t('title.website'), t('title.ePayment')] },
{ match: '/website', labels: [t('title.website')] },
{ match: '/', labels: [t('title.home')] },
]
}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { translate, type BusinessMessageKey } from './messages'
export function useT() {
const { locale } = useLocale()
return useCallback(
(key: BusinessMessageKey, vars?: Record<string, string | number>) =>
translate(locale, key, vars),
[locale],
)
}
+64 -13
View File
@@ -13,6 +13,10 @@
--primary-dark: #2563eb;
--primary-rgb: 59 130 246;
--primary-dark-rgb: 37 99 235;
--chart-accent: #06b6d4;
--chart-accent-dark: #0891b2;
--chart-accent-rgb: 6 182 212;
--chart-accent-dark-rgb: 8 145 178;
--bg-gradient-start: color-mix(in srgb, var(--primary-light) 72%, #ffffff);
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #ffffff);
--bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
@@ -33,8 +37,9 @@
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
}
html {
@@ -47,18 +52,64 @@ body,
}
body {
font-family: var(--font-en);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--bg-gradient-mid);
background-image:
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.28), transparent 72%),
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.18), transparent 72%),
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.12), transparent 72%),
linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-mid) 50%, var(--bg-gradient-end) 100%);
background-image: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 50%,
var(--bg-gradient-end) 100%
);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: -30%;
z-index: -1;
pointer-events: none;
background:
radial-gradient(
ellipse 520px 520px at 72% 18%,
rgba(var(--primary-rgb) / 0.3),
transparent 72%
),
radial-gradient(
ellipse 420px 420px at 22% 82%,
rgba(var(--primary-rgb) / 0.2),
transparent 72%
),
radial-gradient(
ellipse 360px 360px at 8% 42%,
rgba(var(--chart-accent-rgb, var(--primary-rgb)) / 0.14),
transparent 72%
);
animation: pageAuraDrift 22s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes pageAuraDrift {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(3.5%, -2.5%, 0) scale(1.06);
}
100% {
transform: translate3d(-3%, 3.5%, 0) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
body::before {
animation: none;
}
}
button {
@@ -103,7 +154,7 @@ select:focus {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
textarea {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: var(--field-font-size);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
@@ -124,16 +175,16 @@ textarea:focus {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder {
font-family: var(--font-en);
font-family: var(--font-ui);
opacity: 1;
}
[dir='rtl'],
:lang(fa),
.faText {
font-family: var(--font-fa), var(--font-en);
font-weight: 400; /* IRANYekan Regular */
text-align: right;
font-family: var(--font-ui);
font-weight: 400; /* IRANYekan Regular for FA glyphs */
text-align: start;
}
[dir='rtl']::placeholder,
@@ -142,7 +193,7 @@ textarea::placeholder {
input.faText::placeholder,
input[dir='rtl']::placeholder,
input[lang='fa']::placeholder {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-weight: 400;
opacity: 1;
}
+2 -46
View File
@@ -1,47 +1,3 @@
import type { RouteTitleRule } from '@meshkee/dashboard-core'
/** Legacy helpers — prefer `../i18n/messages`. */
export { getBusinessRouteTitleRules } from '../i18n/messages'
export const BUSINESS_DASHBOARD_NAME = 'Business Dashboard'
export const businessRouteTitleRules: RouteTitleRule[] = [
{ match: '/login', labels: ['Sign in'] },
{ match: '/business-profile', labels: ['Business Profile'] },
{ match: '/products/categories', labels: ['Products', 'Categories'] },
{ match: '/products/brands', labels: ['Products', 'Brands'] },
{ match: '/products/new', labels: ['Products', 'Add New Product'] },
{ match: /^\/products\/edit\/[^/]+$/, labels: ['Products', 'Edit Product'] },
{ match: '/products/list', labels: ['Products', 'My Products'] },
{ match: /^\/products\/detail\/[^/]+$/, labels: ['Products', 'Product Details'] },
{ match: '/products/settings', labels: ['Products', 'Settings'] },
{ match: '/products', labels: ['Products'] },
{ match: '/store/items', labels: ['Store', 'My Store Items'] },
{ match: '/store/orders', labels: ['Store', 'My Orders'] },
{ match: '/store/cards', labels: ['Store', 'Shopping Cards'] },
{ match: '/store/settings', labels: ['Store', 'Settings'] },
{ match: '/store', labels: ['Store'] },
{ match: '/customers', labels: ['Customers'] },
{ match: '/blog/list', labels: ['Blog', 'My Blogs'] },
{ match: /^\/blog\/detail\/[^/]+$/, labels: ['Blog', 'Blog Details'] },
{ match: '/blog/new', labels: ['Blog', 'Add New Blog'] },
{ match: /^\/blog\/edit\/[^/]+$/, labels: ['Blog', 'Edit Blog'] },
{ match: '/blog/categories', labels: ['Blog', 'Categories'] },
{ match: '/blog/settings', labels: ['Blog', 'Settings'] },
{ match: '/blog', labels: ['Blog'] },
{ match: '/portfolios/list', labels: ['Portfolios', 'My Portfolios'] },
{ match: /^\/portfolios\/detail\/[^/]+$/, labels: ['Portfolios', 'Portfolio Details'] },
{ match: '/portfolios/new', labels: ['Portfolios', 'Add New Portfolio'] },
{ match: /^\/portfolios\/edit\/[^/]+$/, labels: ['Portfolios', 'Edit Portfolio'] },
{ match: '/portfolios/categories', labels: ['Portfolios', 'Categories'] },
{ match: '/portfolios/settings', labels: ['Portfolios', 'Settings'] },
{ match: '/portfolios', labels: ['Portfolios'] },
{ match: '/website/sliders', labels: ['Website', 'Sliders'] },
{ match: '/website/special-categories', labels: ['Website', 'Special Categories'] },
{ match: '/website/special-brands', labels: ['Website', 'Special Brands'] },
{ match: '/website/special-items', labels: ['Website', 'Special Items'] },
{ match: '/website/contact', labels: ['Website', 'Contact Us Form'] },
{ match: '/website/subscriptions', labels: ['Website', 'Subscriptions'] },
{ match: '/website/faq', labels: ['Website', 'FAQ'] },
{ match: '/website/badges', labels: ['Website', 'Badges'] },
{ match: '/website/e-payment', labels: ['Website', 'E-Payment'] },
{ match: '/website', labels: ['Website'] },
{ match: '/', labels: ['Home'] },
]
@@ -23,10 +23,12 @@ import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT'
export function AddNewProductPage() {
const { id } = useParams()
const navigate = useNavigate()
const t = useT()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
@@ -183,12 +185,10 @@ export function AddNewProductPage() {
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Product' : 'Add a New Product'}
{isEdit ? t('title.editProduct') : t('products.card.new.title')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit
? 'Update product details and save changes.'
: 'Create and publish a new product to your store.'}
{isEdit ? t('products.form.edit.subtitle') : t('products.card.new.desc')}
</p>
</div>
</div>
@@ -130,7 +130,7 @@
}
.selectFieldFa {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
direction: rtl;
text-align: right;
}
+144 -42
View File
@@ -1,105 +1,207 @@
import { useCallback, useEffect, useState } from 'react'
import { CalendarDays } from 'lucide-react'
import {
ShoppingBag,
Store,
Users,
Settings,
FileText,
Briefcase,
Globe,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { SectionCard } from '../components/SectionCard'
import { DailyActivityChart } from '../components/DailyActivityChart'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { listProducts } from '../services/productService'
import { listStoreItems } from '../services/storeItemService'
import { listCustomers } from '../services/customerService'
import { listBlogs } from '../services/blogService'
import { listPortfolios } from '../services/portfolioService'
import {
getCustomersDailyActivity,
getOrdersDailyActivity,
} from '../services/dailyActivityService'
import styles from '../components/PageContent.module.css'
const sections = [
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios'
const sections: {
icon: typeof ShoppingBag
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
countLabelKey?: BusinessMessageKey
href: string
countKey?: CountKey
}[] = [
{
icon: ShoppingBag,
title: 'Products',
description: 'Manage your products, inventory and categories.',
linkText: 'View products',
titleKey: 'home.card.products.title',
descKey: 'home.card.products.desc',
linkKey: 'home.card.products.link',
countLabelKey: 'home.card.products.count',
href: '/products',
countKey: 'products',
},
{
icon: Store,
title: 'Store',
description: 'Manage your store settings, pages and themes.',
linkText: 'View store',
titleKey: 'home.card.store.title',
descKey: 'home.card.store.desc',
linkKey: 'home.card.store.link',
countLabelKey: 'home.card.store.count',
href: '/store',
countKey: 'store',
},
{
icon: Users,
title: 'Customers',
description: 'View and manage your customers and their activity.',
linkText: 'View customers',
titleKey: 'home.card.customers.title',
descKey: 'home.card.customers.desc',
linkKey: 'home.card.customers.link',
countLabelKey: 'home.card.customers.count',
href: '/customers',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure your store preferences and system settings.',
linkText: 'View settings',
href: '/settings',
countKey: 'customers',
},
{
icon: FileText,
title: 'Blog',
description: 'Create and manage blog posts and categories.',
linkText: 'View blog',
titleKey: 'home.card.blog.title',
descKey: 'home.card.blog.desc',
linkKey: 'home.card.blog.link',
countLabelKey: 'home.card.blog.count',
href: '/blog',
countKey: 'blog',
},
{
icon: Briefcase,
title: 'Portfolios',
description: 'Manage your portfolio items and showcase projects.',
linkText: 'View portfolios',
titleKey: 'home.card.portfolios.title',
descKey: 'home.card.portfolios.desc',
linkKey: 'home.card.portfolios.link',
countLabelKey: 'home.card.portfolios.count',
href: '/portfolios',
countKey: 'portfolios',
},
{
icon: Globe,
title: 'Website',
description: 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
linkText: 'View website',
titleKey: 'home.card.website.title',
descKey: 'home.card.website.desc',
linkKey: 'home.card.website.link',
href: '/website',
},
]
function getFormattedDate() {
return new Intl.DateTimeFormat('en-US', {
type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
const [products, store, customers, blog, portfolios] = await Promise.all([
listProducts(1, 1, signal).then((r) => r.total).catch(() => null),
listStoreItems(1, 1, signal).then((r) => r.total).catch(() => null),
listCustomers({ page: 1, pageSize: 1 }, signal).then((r) => r.total).catch(() => null),
listBlogs(1, 1, signal).then((r) => r.total).catch(() => null),
listPortfolios(1, 1, signal).then((r) => r.total).catch(() => null),
])
const counts: SectionCounts = {}
if (products !== null) counts.products = products
if (store !== null) counts.store = store
if (customers !== null) counts.customers = customers
if (blog !== null) counts.blog = blog
if (portfolios !== null) counts.portfolios = portfolios
return counts
}
export function HomePage() {
const { user } = useAuth()
const { locale } = useLocale()
const t = useT()
const [counts, setCounts] = useState<SectionCounts>({})
useEffect(() => {
const controller = new AbortController()
void loadSectionCounts(controller.signal).then((next) => {
if (!controller.signal.aborted) setCounts(next)
})
return () => controller.abort()
}, [])
const loadOrdersActivity = useCallback(
(signal: AbortSignal) => getOrdersDailyActivity(30, signal),
[],
)
const loadCustomersActivity = useCallback(
(signal: AbortSignal) => getCustomersDailyActivity(30, signal),
[],
)
const firstName =
(locale === 'en'
? user?.firstNameEn?.trim() || user?.firstName
: user?.firstName?.trim() || user?.firstNameEn) || t('home.welcomeFallback')
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
weekday: 'long',
}).format(new Date())
}
export function HomePage() {
const { user } = useAuth()
const firstName = user?.firstName || 'there'
return (
<main className={styles.content}>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>
Welcome back, {firstName}! <span aria-hidden="true">👋</span>
</h2>
<p className={styles.pageSubtitle}>
Here&apos;s what&apos;s happening with your store today.
</p>
<h2 className={styles.pageTitle}>{t('home.welcome', { name: firstName })}</h2>
<p className={styles.pageSubtitle}>{t('home.subtitle')}</p>
</div>
<div className={styles.dateBadge}>
<CalendarDays size={16} />
<span>{getFormattedDate()}</span>
<span>{formattedDate}</span>
</div>
</div>
<div className={styles.gridHome}>
{sections.map((section) => (
<SectionCard key={section.title} {...section} />
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
linkText={t(section.linkKey)}
href={section.href}
count={section.countKey ? counts[section.countKey] : undefined}
countLabel={section.countLabelKey ? t(section.countLabelKey) : undefined}
/>
))}
</div>
<div className={styles.grid12}>
<div className={styles.col6}>
<DailyActivityChart
titleKey="home.chart.orders.title"
subtitleKey="home.chart.orders.subtitle"
primaryLegendKey="home.chart.orders.legend"
secondaryLegendKey="home.chart.orders.cartLegend"
loadingKey="home.chart.orders.loading"
errorKey="home.chart.orders.error"
primaryBarTitleKey="home.chart.orders.bar"
secondaryBarTitleKey="home.chart.orders.cartBar"
load={loadOrdersActivity}
/>
</div>
<div className={styles.col6}>
<DailyActivityChart
titleKey="home.chart.customers.title"
subtitleKey="home.chart.customers.subtitle"
primaryLegendKey="home.chart.customers.legend"
secondaryLegendKey="home.chart.customers.activeLegend"
loadingKey="home.chart.customers.loading"
errorKey="home.chart.customers.error"
primaryBarTitleKey="home.chart.customers.bar"
secondaryBarTitleKey="home.chart.customers.activeBar"
load={loadCustomersActivity}
/>
</div>
</div>
</main>
)
}
+9 -4
View File
@@ -24,11 +24,15 @@
.brand {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 12px;
margin-bottom: 28px;
}
.langSelect {
margin-inline-start: auto;
}
.logo {
display: block;
width: 48px;
@@ -112,7 +116,7 @@
.inputIcon {
position: absolute;
left: 12px;
inset-inline-start: 12px;
color: var(--text-muted);
pointer-events: none;
}
@@ -120,7 +124,8 @@
.inputWrap input {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) 40px var(--field-padding-y) 38px;
padding-block: var(--field-padding-y);
padding-inline: 38px 40px;
font-size: var(--field-font-size);
line-height: 1.4;
font-family: inherit;
@@ -140,7 +145,7 @@
.togglePassword {
position: absolute;
right: 12px;
inset-inline-end: 12px;
display: flex;
align-items: center;
justify-content: center;
+78 -76
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
import { LanguageSelect } from '@meshkee/dashboard-ui'
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
@@ -13,6 +14,7 @@ import {
sendOtp,
verifyOtp,
} from '../services/authService'
import { useT } from '../i18n/useT'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css'
@@ -24,6 +26,7 @@ export function LoginPage() {
const { login } = useAuth()
const { businessName, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain()
const t = useT()
const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
@@ -80,7 +83,11 @@ export function LoginPage() {
function handleApiError(err: unknown, fallback: string) {
if (err instanceof ApiError) {
setError(err.message)
if (err.message === BUSINESS_ACCESS_MESSAGE) {
setError(t('login.error.access'))
} else {
setError(err.message)
}
} else {
setError(fallback)
}
@@ -102,7 +109,7 @@ export function LoginPage() {
setSmsStep('code')
startCountdown()
} catch (err) {
handleApiError(err, 'Unable to send verification code.')
handleApiError(err, t('login.error.sendCode'))
} finally {
setIsSubmitting(false)
}
@@ -118,7 +125,7 @@ export function LoginPage() {
await login(cellNumber, password)
navigate('/')
} catch (err) {
handleApiError(err, 'Unable to sign in. Check your connection and try again.')
handleApiError(err, t('login.error.signIn'))
} finally {
setIsSubmitting(false)
}
@@ -129,12 +136,12 @@ export function LoginPage() {
clearMessages()
if (password !== confirmPassword) {
setError('Passwords do not match.')
setError(t('signup.error.match'))
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
setError(t('signup.error.length'))
return
}
@@ -152,16 +159,14 @@ export function LoginPage() {
if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) {
logoutRequest()
setError(
`${BUSINESS_ACCESS_MESSAGE} Customer registration on ${businessDomain} does not grant dashboard access.`,
)
setError(t('login.error.access'))
return
}
setActiveBusiness(data.user)
navigate('/')
} catch (err) {
handleApiError(err, 'Unable to create account.')
handleApiError(err, t('signup.error.create'))
} finally {
setIsSubmitting(false)
}
@@ -172,7 +177,7 @@ export function LoginPage() {
clearMessages()
if (newPassword.length < 8) {
setError('Password must be at least 8 characters.')
setError(t('forgot.error.length'))
return
}
@@ -181,12 +186,10 @@ export function LoginPage() {
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
setInfo(
'Phone number verified. Full password reset via SMS is not available yet — please contact your administrator or sign in if you remember your password.',
)
setInfo(t('forgot.info.partial'))
setTimeout(() => switchView('login'), 2500)
} catch (err) {
handleApiError(err, 'Unable to verify code.')
handleApiError(err, t('forgot.error.verify'))
} finally {
setIsSubmitting(false)
}
@@ -202,14 +205,14 @@ export function LoginPage() {
await verifyOtp(cellNumber, smsCode)
if (!password) {
setError('Enter your account password to complete sign-in after SMS verification.')
setError(t('otp.error.password'))
return
}
await login(cellNumber, password)
navigate('/')
} catch (err) {
handleApiError(err, 'Unable to sign in with SMS verification.')
handleApiError(err, t('otp.error.signIn'))
} finally {
setIsSubmitting(false)
}
@@ -222,14 +225,17 @@ export function LoginPage() {
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
<div className={styles.brandText}>
<span className={styles.businessName}>{businessName || businessDomain}</span>
<span className={styles.appName}>powered by Meshkee.app</span>
<span className={styles.appName}>{t('app.poweredBy')}</span>
</div>
<div className={styles.langSelect}>
<LanguageSelect />
</div>
</div>
{view === 'login' && (
<>
<h1 className={styles.title}>Welcome back</h1>
<p className={styles.subtitle}>Sign in with your mobile number</p>
<h1 className={styles.title}>{t('login.welcome')}</h1>
<p className={styles.subtitle}>{t('login.subtitle')}</p>
<form className={styles.form} onSubmit={handleLogin}>
{error && (
@@ -240,7 +246,7 @@ export function LoginPage() {
{info && <div className={styles.info}>{info}</div>}
<div className={styles.field}>
<label htmlFor="login-phone">Mobile number</label>
<label htmlFor="login-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
@@ -257,13 +263,13 @@ export function LoginPage() {
</div>
<div className={styles.field}>
<label htmlFor="login-password">Password</label>
<label htmlFor="login-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="login-password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
placeholder={t('login.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -274,7 +280,7 @@ export function LoginPage() {
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -289,17 +295,17 @@ export function LoginPage() {
onClick={() => switchView('forgot')}
disabled={isSubmitting}
>
Forgot password?
{t('login.forgot')}
</button>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
{isSubmitting ? t('login.signingIn') : t('login.signIn')}
</button>
</form>
<div className={styles.divider}>
<span>or</span>
<span>{t('login.or')}</span>
</div>
<button
@@ -309,18 +315,18 @@ export function LoginPage() {
disabled={isSubmitting}
>
<KeyRound size={18} />
One-time login with SMS
{t('login.otp')}
</button>
<p className={styles.footerText}>
Don&apos;t have an account?{' '}
{t('login.noAccount')}{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('signup')}
disabled={isSubmitting}
>
Sign up
{t('login.signUp')}
</button>
</p>
</>
@@ -328,8 +334,8 @@ export function LoginPage() {
{view === 'signup' && (
<>
<h1 className={styles.title}>Create account</h1>
<p className={styles.subtitle}>Staff accounts are invited by the business owner</p>
<h1 className={styles.title}>{t('signup.title')}</h1>
<p className={styles.subtitle}>{t('signup.subtitle', { domain: businessDomain })}</p>
<form className={styles.form} onSubmit={handleSignup}>
{error && (
@@ -340,13 +346,13 @@ export function LoginPage() {
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="signup-first">First name</label>
<label htmlFor="signup-first">{t('signup.firstName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-first"
type="text"
placeholder="First name"
placeholder={t('signup.firstName')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
@@ -356,13 +362,13 @@ export function LoginPage() {
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-last">Last name</label>
<label htmlFor="signup-last">{t('signup.lastName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-last"
type="text"
placeholder="Last name"
placeholder={t('signup.lastName')}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
@@ -374,7 +380,7 @@ export function LoginPage() {
</div>
<div className={styles.field}>
<label htmlFor="signup-phone">Mobile number</label>
<label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
@@ -391,13 +397,13 @@ export function LoginPage() {
</div>
<div className={styles.field}>
<label htmlFor="signup-password">Password</label>
<label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-password"
type={showPassword ? 'text' : 'password'}
placeholder="Choose a password"
placeholder={t('signup.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -408,7 +414,7 @@ export function LoginPage() {
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -417,13 +423,13 @@ export function LoginPage() {
</div>
<div className={styles.field}>
<label htmlFor="signup-confirm">Confirm password</label>
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-confirm"
type={showPassword ? 'text' : 'password'}
placeholder="Repeat your password"
placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@@ -434,19 +440,19 @@ export function LoginPage() {
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create account'}
{isSubmitting ? t('signup.creating') : t('signup.create')}
</button>
</form>
<p className={styles.footerText}>
Already have an account?{' '}
{t('signup.hasAccount')}{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
Sign in
{t('signup.signIn')}
</button>
</p>
</>
@@ -461,14 +467,12 @@ export function LoginPage() {
disabled={isSubmitting}
>
<ArrowLeft size={18} />
Back to sign in
{t('forgot.back')}
</button>
<h1 className={styles.title}>Forgot password</h1>
<h1 className={styles.title}>{t('forgot.title')}</h1>
<p className={styles.subtitle}>
{smsStep === 'phone'
? 'We will send a verification code via SMS'
: 'Enter the code and your new password'}
{smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
</p>
<form className={styles.form} onSubmit={handleResetPassword}>
@@ -482,7 +486,7 @@ export function LoginPage() {
{smsStep === 'phone' ? (
<>
<div className={styles.field}>
<label htmlFor="forgot-phone">Mobile number</label>
<label htmlFor="forgot-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
@@ -504,19 +508,17 @@ export function LoginPage() {
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send SMS code'}
{isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
</button>
</>
) : (
<>
{codeSent && (
<p className={styles.codeHint}>
Verification code sent to <strong>{phone}</strong>
</p>
<p className={styles.codeHint}>{t('common.codeSent', { phone })}</p>
)}
<div className={styles.field}>
<label htmlFor="forgot-code">SMS verification code</label>
<label htmlFor="forgot-code">{t('forgot.code')}</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
@@ -534,13 +536,13 @@ export function LoginPage() {
</div>
<div className={styles.field}>
<label htmlFor="forgot-new-password">New password</label>
<label htmlFor="forgot-new-password">{t('forgot.newPassword')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-new-password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter new password"
placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
@@ -552,7 +554,9 @@ export function LoginPage() {
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span>
<span className={styles.countdown}>
{t('common.resendIn', { seconds: countdown })}
</span>
) : (
<button
type="button"
@@ -560,13 +564,13 @@ export function LoginPage() {
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
Resend SMS code
{t('common.resend')}
</button>
)}
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Verifying...' : 'Reset password'}
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button>
</>
)}
@@ -583,14 +587,12 @@ export function LoginPage() {
disabled={isSubmitting}
>
<ArrowLeft size={18} />
Back to sign in
{t('otp.back')}
</button>
<h1 className={styles.title}>One-time login</h1>
<h1 className={styles.title}>{t('otp.title')}</h1>
<p className={styles.subtitle}>
{smsStep === 'phone'
? 'Sign in with a one-time SMS code'
: 'Enter the SMS code and your password'}
{smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
</p>
<form className={styles.form} onSubmit={handleOtpLogin}>
@@ -603,7 +605,7 @@ export function LoginPage() {
{smsStep === 'phone' ? (
<>
<div className={styles.field}>
<label htmlFor="otp-phone">Mobile number</label>
<label htmlFor="otp-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
@@ -625,19 +627,17 @@ export function LoginPage() {
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send SMS code'}
{isSubmitting ? t('otp.sending') : t('otp.sendCode')}
</button>
</>
) : (
<>
{codeSent && (
<p className={styles.codeHint}>
Verification code sent to <strong>{phone}</strong>
</p>
<p className={styles.codeHint}>{t('common.codeSent', { phone })}</p>
)}
<div className={styles.field}>
<label htmlFor="otp-code">SMS verification code</label>
<label htmlFor="otp-code">{t('otp.code')}</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
@@ -655,13 +655,13 @@ export function LoginPage() {
</div>
<div className={styles.field}>
<label htmlFor="otp-password">Password</label>
<label htmlFor="otp-password">{t('otp.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="otp-password"
type={showPassword ? 'text' : 'password'}
placeholder="Your account password"
placeholder={t('otp.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -673,7 +673,9 @@ export function LoginPage() {
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span>
<span className={styles.countdown}>
{t('common.resendIn', { seconds: countdown })}
</span>
) : (
<button
type="button"
@@ -681,13 +683,13 @@ export function LoginPage() {
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
Resend SMS code
{t('common.resend')}
</button>
)}
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
{isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
</button>
</>
)}
+43 -1
View File
@@ -12,6 +12,7 @@ import {
PORTFOLIOS_PER_PAGE,
deletePortfolio,
listPortfolios,
updatePortfolio,
} from '../services/portfolioService'
import type { Portfolio } from '../types/portfolio'
import pageStyles from '../components/PageContent.module.css'
@@ -25,6 +26,7 @@ export function PortfolioListPage() {
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [movingUpId, setMovingUpId] = useState<string | null>(null)
const [error, setError] = useState('')
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
@@ -81,6 +83,43 @@ export function PortfolioListPage() {
}
}
async function handleMoveUp(id: string) {
const index = portfolios.findIndex((item) => item.id === id)
if (index < 0) return
if (index === 0 && currentPage === 1) return
const current = portfolios[index]
setMovingUpId(id)
setError('')
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]
if (current.sortOrder === previous.sortOrder) {
await updatePortfolio(current.id, { sortOrder: previous.sortOrder - 1 })
} else {
await Promise.all([
updatePortfolio(current.id, { sortOrder: previous.sortOrder }),
updatePortfolio(previous.id, { sortOrder: current.sortOrder }),
])
}
}
showToast('Portfolio moved up.', 'success')
await loadPortfolios(currentPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to move portfolio.')
}
} finally {
setMovingUpId(null)
}
}
async function confirmDelete() {
if (!deleteTarget) return
@@ -152,11 +191,14 @@ export function PortfolioListPage() {
) : (
<>
<div className={pageStyles.gridCols4}>
{portfolios.map((portfolio) => (
{portfolios.map((portfolio, index) => (
<PortfolioCard
key={portfolio.id}
portfolio={portfolio}
commentCount={commentCounts[portfolio.id] ?? portfolio.commentCount}
canMoveUp={!(index === 0 && currentPage === 1)}
isMovingUp={movingUpId === portfolio.id}
onMoveUp={handleMoveUp}
onEdit={handleEdit}
onComments={handleComments}
onRemove={handleRemoveRequest}
@@ -139,7 +139,7 @@
}
.description {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: 14px;
font-weight: 300; /* IRANYekan Light */
line-height: 1.7;
@@ -149,7 +149,7 @@
.description:global(.faText),
.description:global(.faText) :where(*) {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-weight: 300;
text-align: justify;
}
+29 -21
View File
@@ -2,47 +2,51 @@ import { FolderTree, PlusCircle, Package, Settings, Tag } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { ProductActivityChart } from '../components/ProductActivityChart'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css'
const productSections = [
const productSections: {
icon: typeof Package
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
href: string
}[] = [
{
icon: Package,
title: 'My Products',
description: 'View, edit and manage all your existing products.',
linkText: 'View products',
titleKey: 'nav.products.list',
descKey: 'products.card.list.desc',
href: '/products/list',
},
{
icon: PlusCircle,
title: 'Add a New Product',
description: 'Create and publish a new product to your store.',
linkText: 'Add product',
titleKey: 'products.card.new.title',
descKey: 'products.card.new.desc',
href: '/products/new',
},
{
icon: FolderTree,
title: 'Categories',
description: 'Organize your products into categories and subcategories.',
linkText: 'View categories',
titleKey: 'nav.products.categories',
descKey: 'products.card.categories.desc',
href: '/products/categories',
},
{
icon: Tag,
title: 'Brands',
description: 'Manage product brands and assign them when creating products.',
linkText: 'View brands',
titleKey: 'nav.products.brands',
descKey: 'products.card.brands.desc',
href: '/products/brands',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure product defaults, variants and display options.',
linkText: 'View settings',
titleKey: 'nav.products.settings',
descKey: 'products.card.settings.desc',
href: '/products/settings',
},
]
export function ProductsPage() {
const t = useT()
return (
<main className={styles.content}>
<Breadcrumbs
@@ -53,16 +57,20 @@ export function ProductsPage() {
/>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>Products</h2>
<p className={styles.pageSubtitle}>
Manage your products, inventory and categories.
</p>
<h2 className={styles.pageTitle}>{t('title.products')}</h2>
<p className={styles.pageSubtitle}>{t('products.overview.subtitle')}</p>
</div>
</div>
<div className={styles.gridHome}>
{productSections.map((section) => (
<SectionCard key={section.title} {...section} />
<SectionCard
key={section.href}
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
href={section.href}
/>
))}
</div>
@@ -55,6 +55,10 @@
transition: border-color 0.2s, box-shadow 0.2s;
}
.stepInputFa {
font-family: var(--font-fa);
}
.stepInput:focus {
outline: none;
border-color: rgba(var(--primary-rgb) / 0.5);
+31 -6
View File
@@ -34,6 +34,7 @@ function normalizeSteps(steps: OrderProcessStep[]) {
return steps.map((step, index) => ({
id: step.id,
label: step.label.trim(),
labelFa: (step.labelFa ?? '').trim(),
color: normalizeStepColor(step.color, defaultStepColorForId(step.id, index)),
}))
}
@@ -43,7 +44,10 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
return a.every((step, index) => {
const other = b[index]
return (
step.id === other.id && step.label === other.label && step.color === other.color
step.id === other.id &&
step.label === other.label &&
step.labelFa === other.labelFa &&
step.color === other.color
)
})
}
@@ -109,6 +113,12 @@ export function StoreSettingsPage() {
)
}
function updateStepLabelFa(id: string, labelFa: string) {
setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, labelFa } : step)),
)
}
function updateStepColor(id: string, color: StepColorPreset) {
setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, color } : step)),
@@ -123,6 +133,7 @@ export function StoreSettingsPage() {
{
id,
label: '',
labelFa: '',
color: defaultStepColorForId(id, current.length),
},
]
@@ -150,13 +161,13 @@ export function StoreSettingsPage() {
async function handleSaveSteps() {
const normalized = normalizeSteps(draftSteps)
const hasEmptyLabel = normalized.some((step) => !step.label)
const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
if (!normalized.length) {
setError('Add at least one order process step.')
return
}
if (hasEmptyLabel) {
setError('Every order step needs a label.')
setError('Every order step needs an English and Farsi label.')
return
}
@@ -186,7 +197,9 @@ export function StoreSettingsPage() {
const canSaveSteps =
stepsDirty &&
draftSteps.length > 0 &&
draftSteps.every((step) => step.label.trim().length > 0)
draftSteps.every(
(step) => step.label.trim().length > 0 && step.labelFa.trim().length > 0,
)
return (
<main className={pageStyles.content}>
@@ -270,10 +283,22 @@ export function StoreSettingsPage() {
type="text"
className={styles.stepInput}
value={step.label}
placeholder="Step label"
aria-label={`Order step ${index + 1}`}
placeholder="Label (EN)"
aria-label={`Order step ${index + 1} English label`}
dir="ltr"
lang="en"
onChange={(e) => updateStepLabel(step.id, e.target.value)}
/>
<input
type="text"
className={`${styles.stepInput} ${styles.stepInputFa}`}
value={step.labelFa ?? ''}
placeholder="عنوان (فارسی)"
aria-label={`Order step ${index + 1} Farsi label`}
dir="rtl"
lang="fa"
onChange={(e) => updateStepLabelFa(step.id, e.target.value)}
/>
<div className={styles.stepControls}>
<Tooltip label="Move step up">
<button
@@ -0,0 +1,41 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
export interface DailyActivityPoint {
date: string
count: number
}
export interface DailyActivitySeries {
days: number
total: number
items: DailyActivityPoint[]
}
export interface DualDailyActivityResponse {
days: number
primary: DailyActivitySeries
secondary: DailyActivitySeries
}
function businessPath(resource: 'orders' | 'customers', suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/${resource}${suffix}`
}
export async function getOrdersDailyActivity(days = 30, signal?: AbortSignal) {
return apiRequest<DualDailyActivityResponse>(
`${businessPath('orders', '/activity')}?days=${days}`,
{ auth: true, signal },
)
}
export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal) {
return apiRequest<DualDailyActivityResponse>(
`${businessPath('customers', '/activity')}?days=${days}`,
{ auth: true, signal },
)
}
@@ -64,6 +64,7 @@ export interface Order {
status: OrderStatus
processStepId: string
processStepLabel?: string | null
processStepLabelFa?: string | null
processStepColor?: string | null
source: OrderSource
subtotal: number
@@ -105,6 +105,7 @@ export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
categoryName: portfolio.categoryName,
tags: portfolio.tags,
titleImageUrl: resolvePortfolioTitleImageUrl(portfolio),
sortOrder: portfolio.sortOrder ?? 0,
commentCount: portfolio.commentCount,
publishedAt: portfolio.publishedAt,
createdAt: portfolio.createdAt,
+26 -4
View File
@@ -5,6 +5,7 @@ import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId
defaultLocale?: 'en' | 'fa'
}
export interface DashboardSettings {
@@ -15,6 +16,7 @@ export interface DashboardSettings {
export interface OrderProcessStep {
id: string
label: string
labelFa: string
color: string
}
@@ -35,10 +37,30 @@ export interface SettingsResponse {
}
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
{ id: 'processing', label: 'Under processing', color: '#3B82F6' },
{ id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' },
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
{
id: 'processing',
label: 'Under processing',
labelFa: 'در حال پردازش',
color: '#3B82F6',
},
{
id: 'ready-for-shipping',
label: 'Ready for shipping',
labelFa: 'آماده ارسال',
color: '#F59E0B',
},
{
id: 'shipped',
label: 'Shipped',
labelFa: 'ارسال‌شده',
color: '#8B5CF6',
},
{
id: 'delivered',
label: 'Delivered',
labelFa: 'تحویل‌شده',
color: '#22C55E',
},
]
function settingsPath() {
@@ -1,4 +1,5 @@
import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
export interface ResolvedTenant {
id: string
@@ -6,6 +7,7 @@ export interface ResolvedTenant {
nameFa: string | null
slug: string
domain: string
defaultLocale?: DashboardLocale
logoUrl?: string | null
faviconUrl?: string | null
}
+2
View File
@@ -17,6 +17,8 @@ export interface AuthUser {
email: string | null
firstName: string | null
lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
cellVerifiedAt: string | null
roles: string[]
dashboard: DashboardType
+1
View File
@@ -17,6 +17,7 @@ export interface Portfolio {
categoryName: string
tags: string[]
titleImageUrl: string | null
sortOrder: number
commentCount: number
publishedAt: string | null
createdAt: string
@@ -11,6 +11,10 @@ const CSS_VAR_DEFAULTS: Record<string, string> = {
'--primary-dark': '#2563eb',
'--primary-rgb': '59 130 246',
'--primary-dark-rgb': '37 99 235',
'--chart-accent': '#06b6d4',
'--chart-accent-dark': '#0891b2',
'--chart-accent-rgb': '6 182 212',
'--chart-accent-dark-rgb': '8 145 178',
}
export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | null) {
@@ -23,6 +27,10 @@ export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | nul
root.style.setProperty('--primary-dark', tokens.primaryDark)
root.style.setProperty('--primary-rgb', tokens.primaryRgb)
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb)
root.style.setProperty('--chart-accent', tokens.chartAccent)
root.style.setProperty('--chart-accent-dark', tokens.chartAccentDark)
root.style.setProperty('--chart-accent-rgb', tokens.chartAccentRgb)
root.style.setProperty('--chart-accent-dark-rgb', tokens.chartAccentDarkRgb)
}
export function resetBusinessPrimaryColor() {
@@ -20,8 +20,27 @@ export type BusinessPrimaryColorTokens = {
primaryGlow: string
primaryRgb: string
primaryDarkRgb: string
/** Second chart series color (red→purple, blue→cyan, …). */
chartAccent: string
chartAccentDark: string
chartAccentRgb: string
chartAccentDarkRgb: string
}
const PURPLE_ACCENT = {
chartAccent: '#a855f7',
chartAccentDark: '#9333ea',
chartAccentRgb: '168 85 247',
chartAccentDarkRgb: '147 51 234',
} as const
const CYAN_ACCENT = {
chartAccent: '#06b6d4',
chartAccentDark: '#0891b2',
chartAccentRgb: '6 182 212',
chartAccentDarkRgb: '8 145 178',
} as const
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId,
BusinessPrimaryColorTokens
@@ -34,6 +53,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#ef4444',
primaryRgb: '239 68 68',
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
},
yellow: {
label: 'Yellow',
@@ -43,6 +63,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#eab308',
primaryRgb: '234 179 8',
primaryDarkRgb: '202 138 4',
...PURPLE_ACCENT,
},
black: {
label: 'Black',
@@ -52,6 +73,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#334155',
primaryRgb: '30 41 59',
primaryDarkRgb: '15 23 42',
...CYAN_ACCENT,
},
cyan: {
label: 'Cyan',
@@ -61,6 +83,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#06b6d4',
primaryRgb: '6 182 212',
primaryDarkRgb: '8 145 178',
...PURPLE_ACCENT,
},
purple: {
label: 'Purple',
@@ -70,6 +93,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#a855f7',
primaryRgb: '168 85 247',
primaryDarkRgb: '147 51 234',
...CYAN_ACCENT,
},
'light-blue': {
label: 'Light Blue',
@@ -79,6 +103,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#38bdf8',
primaryRgb: '56 189 248',
primaryDarkRgb: '14 165 233',
...CYAN_ACCENT,
},
'dark-blue': {
label: 'Dark Blue',
@@ -88,6 +113,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#3b82f6',
primaryRgb: '59 130 246',
primaryDarkRgb: '37 99 235',
...CYAN_ACCENT,
},
}
+11 -4
View File
@@ -14,9 +14,13 @@ function toMonthKey(iso: string): string {
return `${year}-${month}`
}
function formatMonthLabel(monthKey: string): string {
function formatMonthLabel(monthKey: string, locale: string): string {
const [year, month] = monthKey.split('-').map(Number)
return new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'short' })
return new Date(year, month - 1, 1).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
month: 'short',
calendar: 'gregory',
numberingSystem: 'latn',
})
}
export function buildLast12MonthKeys(): string[] {
@@ -33,7 +37,10 @@ export function buildLast12MonthKeys(): string[] {
return keys
}
export function aggregateProductActivity(products: ProductApi[]): ProductMonthActivity[] {
export function aggregateProductActivity(
products: ProductApi[],
locale: string = 'en',
): ProductMonthActivity[] {
const monthKeys = buildLast12MonthKeys()
const added = new Map(monthKeys.map((key) => [key, 0]))
const updated = new Map(monthKeys.map((key) => [key, 0]))
@@ -56,7 +63,7 @@ export function aggregateProductActivity(products: ProductApi[]): ProductMonthAc
return monthKeys.map((monthKey) => ({
monthKey,
label: formatMonthLabel(monthKey),
label: formatMonthLabel(monthKey, locale),
added: added.get(monthKey) ?? 0,
updated: updated.get(monthKey) ?? 0,
}))