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
+13 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link <link
@@ -15,6 +15,18 @@
rel="stylesheet" rel="stylesheet"
/> />
<title>Business Dashboard</title> <title>Business Dashboard</title>
<script>
try {
var l = localStorage.getItem('meshkee.dashboard.locale')
if (l === 'en') {
document.documentElement.lang = 'en'
document.documentElement.dir = 'ltr'
} else {
document.documentElement.lang = 'fa'
document.documentElement.dir = 'rtl'
}
} catch (e) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3
View File
@@ -4,6 +4,7 @@ import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
import { BusinessThemeProvider } from './context/BusinessThemeContext' import { BusinessThemeProvider } from './context/BusinessThemeContext'
import { TenantBrandingProvider } from './context/TenantBrandingContext' import { TenantBrandingProvider } from './context/TenantBrandingContext'
import { LocaleProvider } from '@meshkee/dashboard-ui'
import { ToastProvider } from './context/ToastContext' import { ToastProvider } from './context/ToastContext'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute' import { GuestRoute } from './components/GuestRoute'
@@ -50,6 +51,7 @@ import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
function App() { function App() {
return ( return (
<BusinessDomainGuard> <BusinessDomainGuard>
<LocaleProvider>
<BrowserRouter> <BrowserRouter>
<TenantBrandingProvider> <TenantBrandingProvider>
<DashboardDocumentTitle /> <DashboardDocumentTitle />
@@ -111,6 +113,7 @@ function App() {
</AuthProvider> </AuthProvider>
</TenantBrandingProvider> </TenantBrandingProvider>
</BrowserRouter> </BrowserRouter>
</LocaleProvider>
</BusinessDomainGuard> </BusinessDomainGuard>
) )
} }
@@ -21,6 +21,10 @@
color: var(--text-muted); color: var(--text-muted);
} }
:global([dir='rtl']) .separator {
transform: scaleX(-1);
}
.link { .link {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
+10 -3
View File
@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react' 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' import styles from './Breadcrumbs.module.css'
export interface BreadcrumbItem { export interface BreadcrumbItem {
@@ -12,11 +15,15 @@ interface BreadcrumbsProps {
} }
export function Breadcrumbs({ items }: BreadcrumbsProps) { export function Breadcrumbs({ items }: BreadcrumbsProps) {
const { locale } = useLocale()
const t = useT()
return ( return (
<nav className={styles.breadcrumbs} aria-label="Breadcrumb"> <nav className={styles.breadcrumbs} aria-label={t('common.breadcrumb')}>
<ol className={styles.list}> <ol className={styles.list}>
{items.map((item, index) => { {items.map((item, index) => {
const isLast = index === items.length - 1 const isLast = index === items.length - 1
const label = translateBreadcrumbLabel(locale, item.label)
return ( return (
<li key={`${item.label}-${index}`} className={styles.item}> <li key={`${item.label}-${index}`} className={styles.item}>
{index > 0 && ( {index > 0 && (
@@ -24,10 +31,10 @@ export function Breadcrumbs({ items }: BreadcrumbsProps) {
)} )}
{item.href && !isLast ? ( {item.href && !isLast ? (
<Link to={item.href} className={styles.link}> <Link to={item.href} className={styles.link}>
{item.label} {label}
</Link> </Link>
) : ( ) : (
<span className={isLast ? styles.current : styles.text}>{item.label}</span> <span className={isLast ? styles.current : styles.text}>{label}</span>
)} )}
</li> </li>
) )
@@ -46,16 +46,24 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
margin-left: 4px; margin-inline-start: 4px;
color: var(--text-muted); color: var(--text-muted);
transition: transform 0.2s ease; transition: transform 0.2s ease;
vertical-align: middle; vertical-align: middle;
} }
:global([dir='rtl']) .chevron {
transform: scaleX(-1);
}
.chevronOpen { .chevronOpen {
transform: rotate(90deg); transform: rotate(90deg);
} }
:global([dir='rtl']) .chevronOpen {
transform: scaleX(-1) rotate(90deg);
}
.names { .names {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -75,7 +83,7 @@
} }
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: var(--text-primary); 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 { useLocation } from 'react-router-dom'
import { useDashboardDocumentTitle } from '@meshkee/dashboard-ui' import { useDashboardDocumentTitle, useLocale } from '@meshkee/dashboard-ui'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { BUSINESS_DASHBOARD_NAME, businessRouteTitleRules } from '../lib/routeTitles' import { getBusinessRouteTitleRules, translate } from '../i18n/messages'
export function DashboardDocumentTitle() { export function DashboardDocumentTitle() {
const { pathname } = useLocation() const { pathname } = useLocation()
const { businessName } = useTenantBranding() const { businessName } = useTenantBranding()
const { locale } = useLocale()
useDashboardDocumentTitle({ useDashboardDocumentTitle({
businessName, businessName,
dashboardName: BUSINESS_DASHBOARD_NAME, dashboardName: translate(locale, 'app.dashboardName'),
pathname, pathname,
routeRules: businessRouteTitleRules, routeRules: getBusinessRouteTitleRules(locale),
}) })
return null return null
+8 -12
View File
@@ -60,9 +60,10 @@
.badge { .badge {
position: absolute; position: absolute;
top: 4px; top: 4px;
right: 4px; inset-inline-end: 4px;
width: 18px; min-width: 18px;
height: 18px; height: 18px;
padding: 0 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -70,7 +71,8 @@
color: white; color: white;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
border-radius: 50%; line-height: 1;
border-radius: 999px;
border: 2px solid white; border: 2px solid white;
} }
@@ -82,7 +84,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 6px 12px 6px 6px; padding-block: 6px;
padding-inline: 6px 12px;
border-radius: 50px; border-radius: 50px;
background: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
@@ -96,13 +99,6 @@
border-color: rgba(var(--primary-rgb) / 0.25); border-color: rgba(var(--primary-rgb) / 0.25);
} }
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo { .profileInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -132,7 +128,7 @@
.dropdown { .dropdown {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; inset-inline-end: 0;
min-width: 180px; min-width: 180px;
padding: 6px; padding: 6px;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
+59 -35
View File
@@ -1,32 +1,52 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, Settings, KeyRound, LogOut } from 'lucide-react' 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 { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { changePassword } from '../services/authService' import { changePassword } from '../services/authService'
import styles from './Header.module.css' import styles from './Header.module.css'
const profileMenuItems = [ function displayUserName(
{ icon: User, label: 'Profile', to: '/profile' }, user: {
{ icon: Settings, label: 'Setting', to: '/settings' }, 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() { export function Header() {
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false) const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const displayName = const displayName = displayUserName(user, locale, t('app.userFallback'))
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.cellNumber || 'User'
const roleLabel = const roleLabel =
user?.roleLabel ?? user?.roleLabel ??
(user?.isSuperAdmin || user?.roles.includes('super_admin') (user?.isSuperAdmin || user?.roles.includes('super_admin')
? 'Super Admin' ? t('role.superAdmin')
: user?.businesses[0]?.isOwner : user?.businesses[0]?.isOwner
? 'Business Owner' ? t('role.owner')
: user?.businesses[0]?.teamRole ?? 'Staff') : user?.businesses[0]?.teamRole ?? t('role.staff'))
useEffect(() => { useEffect(() => {
if (!menuOpen) return if (!menuOpen) return
@@ -64,21 +84,23 @@ export function Header() {
<> <>
<header className={styles.header}> <header className={styles.header}>
<div className={styles.left}> <div className={styles.left}>
<button className={styles.menuBtn} aria-label="Toggle menu"> <button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} /> <Menu size={22} />
</button> </button>
<h1 className={styles.title}>Admin Dashboard</h1> <h1 className={styles.title}>{t('header.title')}</h1>
</div> </div>
<div className={styles.right}> <div className={styles.right}>
<button className={styles.iconBtn} aria-label="Messages"> <LanguageSelect />
<button className={styles.iconBtn} aria-label={t('header.messages')}>
<MessageSquare size={20} /> <MessageSquare size={20} />
<span className={styles.badge}>5</span> <span className={styles.badge}>0</span>
</button> </button>
<button className={styles.iconBtn} aria-label="Notifications"> <button className={styles.iconBtn} aria-label={t('header.notifications')}>
<Bell size={20} /> <Bell size={20} />
<span className={styles.badge}>3</span> <span className={styles.badge}>0</span>
</button> </button>
<div className={styles.profileWrap} ref={menuRef}> <div className={styles.profileWrap} ref={menuRef}>
@@ -89,11 +111,6 @@ export function Header() {
aria-expanded={menuOpen} aria-expanded={menuOpen}
aria-haspopup="menu" 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}> <div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span> <span className={styles.name}>{displayName}</span>
<span className={styles.role}>{roleLabel}</span> <span className={styles.role}>{roleLabel}</span>
@@ -106,18 +123,24 @@ export function Header() {
{menuOpen && ( {menuOpen && (
<div className={styles.dropdown} role="menu"> <div className={styles.dropdown} role="menu">
{profileMenuItems.map(({ icon: Icon, label, to }) => ( <Link
<Link to="/profile"
key={label} className={styles.dropdownItem}
to={to} role="menuitem"
className={styles.dropdownItem} onClick={() => setMenuOpen(false)}
role="menuitem" >
onClick={() => setMenuOpen(false)} <User size={16} />
> <span>{t('header.profile')}</span>
<Icon size={16} /> </Link>
<span>{label}</span> <Link
</Link> to="/settings"
))} className={styles.dropdownItem}
role="menuitem"
onClick={() => setMenuOpen(false)}
>
<Settings size={16} />
<span>{t('header.setting')}</span>
</Link>
<button <button
type="button" type="button"
className={styles.dropdownItem} className={styles.dropdownItem}
@@ -125,7 +148,7 @@ export function Header() {
onClick={openPasswordModal} onClick={openPasswordModal}
> >
<KeyRound size={16} /> <KeyRound size={16} />
<span>Change password</span> <span>{t('header.changePassword')}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -134,7 +157,7 @@ export function Header() {
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut size={16} /> <LogOut size={16} />
<span>Logout</span> <span>{t('nav.logout')}</span>
</button> </button>
</div> </div>
)} )}
@@ -146,6 +169,7 @@ export function Header() {
open={passwordModalOpen} open={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)} onClose={() => setPasswordModalOpen(false)}
onChangePassword={changePassword} onChangePassword={changePassword}
title={t('header.changePassword')}
/> />
</> </>
) )
@@ -4,7 +4,7 @@
} }
.main { .main {
margin-left: var(--sidebar-width); margin-inline-start: var(--sidebar-width);
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -12,6 +12,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.main { .main {
margin-left: 0; margin-inline-start: 0;
} }
} }
@@ -64,7 +64,7 @@
.titleFa { .titleFa {
margin: 0; margin: 0;
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
+17 -1
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom' 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 type { Portfolio } from '../types/portfolio'
import { formatPortfolioCardDate } from '../services/portfolioService' import { formatPortfolioCardDate } from '../services/portfolioService'
import { textLocaleAttrs } from '../utils/textLocale' import { textLocaleAttrs } from '../utils/textLocale'
@@ -10,6 +10,9 @@ import controlStyles from './ProductCard.module.css'
interface PortfolioCardProps { interface PortfolioCardProps {
portfolio: Portfolio portfolio: Portfolio
commentCount: number commentCount: number
canMoveUp: boolean
isMovingUp?: boolean
onMoveUp: (id: string) => void
onEdit: (id: string) => void onEdit: (id: string) => void
onComments: (id: string) => void onComments: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
@@ -18,6 +21,9 @@ interface PortfolioCardProps {
export function PortfolioCard({ export function PortfolioCard({
portfolio, portfolio,
commentCount, commentCount,
canMoveUp,
isMovingUp = false,
onMoveUp,
onEdit, onEdit,
onComments, onComments,
onRemove, onRemove,
@@ -87,6 +93,16 @@ export function PortfolioCard({
</Link> </Link>
<div className={controlStyles.controls}> <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"> <Tooltip label="Edit portfolio">
<button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit"> <button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit">
<Pencil size={16} /> <Pencil size={16} />
@@ -53,7 +53,7 @@
} }
.legendUpdated { .legendUpdated {
background: var(--primary-dark); background: var(--chart-accent, #a855f7);
} }
.status, .status,
@@ -115,8 +115,8 @@
.barUpdated { .barUpdated {
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
color-mix(in srgb, var(--primary-dark) 55%, #ffffff) 0%, color-mix(in srgb, var(--chart-accent, #a855f7) 55%, #ffffff) 0%,
var(--primary-dark) 100% var(--chart-accent, #a855f7) 100%
); );
} }
@@ -1,13 +1,17 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService' import { listAllProducts } from '../services/productService'
import { aggregateProductActivity, type ProductMonthActivity } from '../utils/productActivity' import { aggregateProductActivity, type ProductMonthActivity } from '../utils/productActivity'
import { useT } from '../i18n/useT'
import styles from './ProductActivityChart.module.css' import styles from './ProductActivityChart.module.css'
const CHART_HEIGHT = 200 const CHART_HEIGHT = 200
const BAR_GAP = 6 const BAR_GAP = 6
export function ProductActivityChart() { export function ProductActivityChart() {
const t = useT()
const { locale } = useLocale()
const [data, setData] = useState<ProductMonthActivity[]>([]) const [data, setData] = useState<ProductMonthActivity[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -16,7 +20,7 @@ export function ProductActivityChart() {
const controller = new AbortController() const controller = new AbortController()
void loadActivity(controller.signal) void loadActivity(controller.signal)
return () => controller.abort() return () => controller.abort()
}, []) }, [locale])
async function loadActivity(signal?: AbortSignal) { async function loadActivity(signal?: AbortSignal) {
setIsLoading(true) setIsLoading(true)
@@ -24,13 +28,13 @@ export function ProductActivityChart() {
try { try {
const products = await listAllProducts(signal) const products = await listAllProducts(signal)
setData(aggregateProductActivity(products)) setData(aggregateProductActivity(products, locale))
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load product activity.') setError(t('products.activity.error'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -51,26 +55,26 @@ export function ProductActivityChart() {
) )
return ( 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 className={styles.header}>
<div> <div>
<h3 className={styles.title}>Product activity</h3> <h3 className={styles.title}>{t('products.activity.title')}</h3>
<p className={styles.subtitle}>Products added or updated in the last 12 months</p> <p className={styles.subtitle}>{t('products.activity.subtitle')}</p>
</div> </div>
<div className={styles.legend}> <div className={styles.legend}>
<span className={styles.legendItem}> <span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.legendAdded}`} /> <span className={`${styles.legendDot} ${styles.legendAdded}`} />
Added ({totals.added}) {t('products.activity.added', { count: totals.added })}
</span> </span>
<span className={styles.legendItem}> <span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.legendUpdated}`} /> <span className={`${styles.legendDot} ${styles.legendUpdated}`} />
Updated ({totals.updated}) {t('products.activity.updated', { count: totals.updated })}
</span> </span>
</div> </div>
</div> </div>
{isLoading ? ( {isLoading ? (
<p className={styles.status}>Loading chart...</p> <p className={styles.status}>{t('products.activity.loading')}</p>
) : error ? ( ) : error ? (
<p className={styles.error} role="alert"> <p className={styles.error} role="alert">
{error} {error}
@@ -81,7 +85,7 @@ export function ProductActivityChart() {
className={styles.chart} className={styles.chart}
style={{ height: CHART_HEIGHT + 32 }} style={{ height: CHART_HEIGHT + 32 }}
role="img" role="img"
aria-label="Bar chart of products added and updated per month" aria-label={t('products.activity.chartAria')}
> >
{data.map((item) => { {data.map((item) => {
const addedHeight = (item.added / maxValue) * CHART_HEIGHT const addedHeight = (item.added / maxValue) * CHART_HEIGHT
@@ -93,12 +97,18 @@ export function ProductActivityChart() {
<div <div
className={`${styles.bar} ${styles.barAdded}`} className={`${styles.bar} ${styles.barAdded}`}
style={{ height: Math.max(addedHeight, item.added > 0 ? 4 : 0) }} 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 <div
className={`${styles.bar} ${styles.barUpdated}`} className={`${styles.bar} ${styles.barUpdated}`}
style={{ height: Math.max(updatedHeight, item.updated > 0 ? 4 : 0) }} 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> </div>
<span className={styles.label}>{item.label}</span> <span className={styles.label}>{item.label}</span>
@@ -79,7 +79,7 @@
} }
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
@@ -160,6 +160,12 @@
color: var(--primary); color: var(--primary);
} }
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.controls button.danger:hover { .controls button.danger:hover {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
color: #ef4444; color: #ef4444;
@@ -130,7 +130,7 @@
} }
.optionFa { .optionFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-weight: 500; font-weight: 500;
direction: rtl; direction: rtl;
} }
@@ -56,28 +56,78 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
min-height: 36px;
} }
.link { .countMeta {
font-size: 14px; display: inline-flex;
font-weight: 500; 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); 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 { .arrowBtn {
width: 36px; width: 36px;
height: 36px; height: 36px;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1); background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary); 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 { .card:hover .arrowBtn {
background: var(--primary); background: var(--primary);
color: white; color: white;
transform: translateX(2px); }
:global([dir='rtl']) .arrowBtn {
transform: scaleX(-1);
} }
+22 -4
View File
@@ -6,17 +6,26 @@ interface SectionCardProps {
icon: LucideIcon icon: LucideIcon
title: string title: string
description: string description: string
linkText: string linkText?: string
href: 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({ export function SectionCard({
icon: Icon, icon: Icon,
title, title,
description, description,
linkText,
href, href,
count,
countLabel,
}: SectionCardProps) { }: SectionCardProps) {
const showCount = typeof count === 'number' && Number.isFinite(count)
const formattedCount = showCount
? new Intl.NumberFormat('en-US').format(count)
: null
return ( return (
<Link to={href} className={styles.card}> <Link to={href} className={styles.card}>
<div className={styles.iconWrap}> <div className={styles.iconWrap}>
@@ -27,9 +36,18 @@ export function SectionCard({
<p className={styles.description}>{description}</p> <p className={styles.description}>{description}</p>
<div className={styles.footer}> <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"> <span className={styles.arrowBtn} aria-hidden="true">
<ArrowRight size={18} /> <ArrowRight size={18} className={styles.arrowIcon} />
</span> </span>
</div> </div>
</Link> </Link>
+15 -14
View File
@@ -1,7 +1,7 @@
.sidebar { .sidebar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; inset-inline-start: 0;
width: var(--sidebar-width); width: var(--sidebar-width);
height: 100vh; height: 100vh;
display: flex; display: flex;
@@ -10,7 +10,7 @@
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-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; z-index: 100;
} }
@@ -45,26 +45,26 @@
} }
.brandDomain { .brandDomain {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.brandName {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
} }
.brandName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.nav { .nav {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
overflow-y: auto; overflow-y: auto;
padding-right: 2px; padding-inline-end: 2px;
} }
.navGroup { .navGroup {
@@ -83,7 +83,7 @@
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.2s ease; transition: all 0.2s ease;
width: 100%; width: 100%;
text-align: left; text-align: start;
} }
.navItem:hover { .navItem:hover {
@@ -118,9 +118,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
margin: 2px 0 4px 12px; margin-block: 2px 4px;
padding-left: 12px; margin-inline-start: 12px;
border-left: 2px solid rgba(148, 163, 184, 0.2); padding-inline-start: 12px;
border-inline-start: 2px solid rgba(148, 163, 184, 0.2);
} }
.subNavItem { .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 { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { import {
Home, Home,
@@ -15,7 +15,10 @@ import {
Building2, Building2,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { import {
BUSINESS_PROFILE_UPDATED_EVENT, BUSINESS_PROFILE_UPDATED_EVENT,
getActiveBusinessDomain, getActiveBusinessDomain,
@@ -26,111 +29,29 @@ import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
interface NavChild { interface NavChild {
label: string labelKey: BusinessMessageKey
to: string to: string
} }
interface NavGroup { interface NavGroup {
type: 'group' type: 'group'
id: string
icon: LucideIcon icon: LucideIcon
label: string labelKey: BusinessMessageKey
basePath: string basePath: string
children: NavChild[] children: NavChild[]
} }
interface NavLinkItem { interface NavLinkItem {
type: 'link' type: 'link'
id: string
icon: LucideIcon icon: LucideIcon
label: string labelKey: BusinessMessageKey
to: string to: string
} }
type NavItem = NavLinkItem | NavGroup 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) { function isGroupActive(basePath: string, pathname: string) {
return pathname === basePath || pathname.startsWith(`${basePath}/`) return pathname === basePath || pathname.startsWith(`${basePath}/`)
} }
@@ -139,12 +60,115 @@ export function Sidebar() {
const { pathname } = useLocation() const { pathname } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({}) const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const [brandLogoUrl, setBrandLogoUrl] = useState<string | null>(null) const [brandLogoUrl, setBrandLogoUrl] = useState<string | null>(null)
const [brandName, setBrandName] = useState('') const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const businessDomain = getActiveBusinessDomain() 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(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -153,11 +177,13 @@ export function Sidebar() {
try { try {
const data = await getBusinessProfile(controller.signal) const data = await getBusinessProfile(controller.signal)
setBrandLogoUrl(data.profile.logoUrl) 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) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err)) return
setBrandLogoUrl(null) setBrandLogoUrl(null)
setBrandName(fallbackBusinessName) setNameEn('')
setNameFa('')
} }
} }
@@ -172,18 +198,18 @@ export function Sidebar() {
controller.abort() controller.abort()
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated) window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
} }
}, [fallbackBusinessName]) }, [])
useEffect(() => { useEffect(() => {
navItems.forEach((item) => { navItems.forEach((item) => {
if (item.type === 'group' && isGroupActive(item.basePath, pathname)) { 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) { function toggleGroup(id: string) {
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] })) setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] }))
} }
return ( return (
@@ -191,12 +217,12 @@ export function Sidebar() {
<div className={styles.brand}> <div className={styles.brand}>
<img <img
src={brandLogoUrl ?? meshkeeLogo} src={brandLogoUrl ?? meshkeeLogo}
alt={brandName || 'Business logo'} alt={brandName || t('app.storeFallback')}
className={`${styles.brandLogo} ${brandLogoUrl ? styles.brandLogoUploaded : ''}`} className={`${styles.brandLogo} ${brandLogoUrl ? styles.brandLogoUploaded : ''}`}
/> />
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.brandDomain}>{businessDomain}</span>
<span className={styles.brandName}>{brandName || fallbackBusinessName}</span> <span className={styles.brandName}>{brandName || fallbackBusinessName}</span>
<span className={styles.brandDomain}>{businessDomain}</span>
</div> </div>
</div> </div>
@@ -205,7 +231,7 @@ export function Sidebar() {
if (item.type === 'link') { if (item.type === 'link') {
return ( return (
<NavLink <NavLink
key={item.label} key={item.id}
to={item.to} to={item.to}
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => className={({ isActive }) =>
@@ -213,24 +239,24 @@ export function Sidebar() {
} }
> >
<item.icon size={20} /> <item.icon size={20} />
<span>{item.label}</span> <span>{t(item.labelKey)}</span>
</NavLink> </NavLink>
) )
} }
const isOpen = openGroups[item.label] ?? false const isOpen = openGroups[item.id] ?? false
const groupActive = isGroupActive(item.basePath, pathname) const groupActive = isGroupActive(item.basePath, pathname)
return ( return (
<div key={item.label} className={styles.navGroup}> <div key={item.id} className={styles.navGroup}>
<button <button
type="button" type="button"
className={`${styles.navItem} ${styles.navGroupBtn} ${groupActive ? styles.active : ''}`} className={`${styles.navItem} ${styles.navGroupBtn} ${groupActive ? styles.active : ''}`}
onClick={() => toggleGroup(item.label)} onClick={() => toggleGroup(item.id)}
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<item.icon size={20} /> <item.icon size={20} />
<span className={styles.navGroupLabel}>{item.label}</span> <span className={styles.navGroupLabel}>{t(item.labelKey)}</span>
<ChevronDown <ChevronDown
size={16} size={16}
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`} className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
@@ -248,7 +274,7 @@ export function Sidebar() {
`${styles.subNavItem} ${isActive ? styles.subNavActive : ''}` `${styles.subNavItem} ${isActive ? styles.subNavActive : ''}`
} }
> >
{child.label} {t(child.labelKey)}
</NavLink> </NavLink>
))} ))}
</div> </div>
@@ -259,22 +285,21 @@ export function Sidebar() {
</nav> </nav>
<div className={styles.footer}> <div className={styles.footer}>
{footerItems.map(({ icon: Icon, label }) => ( <button type="button" className={styles.navItem}>
<button <HelpCircle size={20} />
key={label} <span>{t('nav.help')}</span>
type="button" </button>
className={styles.navItem} <button
onClick={() => { type="button"
if (label === 'Logout') { className={styles.navItem}
logout() onClick={() => {
navigate('/login') logout()
} navigate('/login')
}} }}
> >
<Icon size={20} /> <LogOut size={20} />
<span>{label}</span> <span>{t('nav.logout')}</span>
</button> </button>
))}
</div> </div>
</aside> </aside>
) )
@@ -101,7 +101,7 @@
} }
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
@@ -31,7 +31,7 @@
.nameFa { .nameFa {
margin-top: 4px; margin-top: 4px;
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
@@ -4,10 +4,12 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { applyDocumentFavicon } from '@meshkee/dashboard-core' import { applyDocumentFavicon } from '@meshkee/dashboard-core'
import { useLocale } from '@meshkee/dashboard-ui'
import { isAbortError } from '../lib/api' import { isAbortError } from '../lib/api'
import { getBusinessDomain } from '../lib/config' import { getBusinessDomain } from '../lib/config'
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext' import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
@@ -34,10 +36,13 @@ function pickBusinessName(
} }
export function TenantBrandingProvider({ children }: { children: ReactNode }) { 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 [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null) const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const [refreshToken, setRefreshToken] = useState(0) const [refreshToken, setRefreshToken] = useState(0)
const defaultLocaleAppliedRef = useRef(false)
const refreshBranding = useCallback(() => { const refreshBranding = useCallback(() => {
setRefreshToken((value) => value + 1) setRefreshToken((value) => value + 1)
@@ -52,7 +57,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const tenant = await resolveTenantByDomain(domain) const tenant = await resolveTenantByDomain(domain)
if (controller.signal.aborted) return 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 nextLogo = tenant.logoUrl?.trim() || null
let nextFavicon = let nextFavicon =
tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null tenant.faviconUrl?.trim() || tenant.logoUrl?.trim() || null
@@ -60,10 +66,15 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
try { try {
const profile = await getBusinessProfile(controller.signal) const profile = await getBusinessProfile(controller.signal)
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
name = pickBusinessName( nextNameEn = pickBusinessName(
profile.profile.nameEn, profile.profile.nameEn,
nextNameEn,
domain,
)
nextNameFa = pickBusinessName(
profile.profile.nameFa, profile.profile.nameFa,
name, profile.profile.nameEn,
nextNameFa,
domain, domain,
) )
nextLogo = profile.profile.logoUrl?.trim() || nextLogo nextLogo = profile.profile.logoUrl?.trim() || nextLogo
@@ -79,14 +90,25 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
} }
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setBusinessName(name || domain) setNameEn(nextNameEn || domain)
setNameFa(nextNameFa || nextNameEn || domain)
setLogoUrl(nextLogo) setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon) setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon) applyDocumentFavicon(nextFavicon)
if (!defaultLocaleAppliedRef.current) {
defaultLocaleAppliedRef.current = true
if (tenant.defaultLocale === 'en' || tenant.defaultLocale === 'fa') {
setLocale(tenant.defaultLocale)
} else {
setLocale('fa')
}
}
} }
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain) const domain = getBusinessDomain()
setNameEn(domain)
setNameFa(domain)
setLogoUrl(null) setLogoUrl(null)
setFaviconUrl(null) setFaviconUrl(null)
applyDocumentFavicon(null) applyDocumentFavicon(null)
@@ -104,7 +126,14 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
controller.abort() controller.abort()
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated) 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( const value = useMemo(
() => ({ businessName, logoUrl, faviconUrl, refreshBranding }), () => ({ 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-dark: #2563eb;
--primary-rgb: 59 130 246; --primary-rgb: 59 130 246;
--primary-dark-rgb: 37 99 235; --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-start: color-mix(in srgb, var(--primary-light) 72%, #ffffff);
--bg-gradient-mid: color-mix(in srgb, var(--primary-light) 42%, #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); --bg-gradient-end: color-mix(in srgb, var(--primary-light) 18%, #ffffff);
@@ -33,8 +37,9 @@
--field-padding-y: 9px; --field-padding-y: 9px;
--field-padding-x: 12px; --field-padding-x: 12px;
--field-height: 38px; --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-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
} }
html { html {
@@ -47,18 +52,64 @@ body,
} }
body { body {
font-family: var(--font-en); font-family: var(--font-ui);
color: var(--text-primary); color: var(--text-primary);
background-color: var(--bg-gradient-mid); background-color: var(--bg-gradient-mid);
background-image: background-image: linear-gradient(
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.28), transparent 72%), 135deg,
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.18), transparent 72%), var(--bg-gradient-start) 0%,
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.12), transparent 72%), var(--bg-gradient-mid) 50%,
linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-mid) 50%, var(--bg-gradient-end) 100%); var(--bg-gradient-end) 100%
);
background-attachment: fixed; background-attachment: fixed;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
-webkit-font-smoothing: antialiased; -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 { button {
@@ -103,7 +154,7 @@ select:focus {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']), input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
textarea { textarea {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: var(--field-font-size); font-size: var(--field-font-size);
outline: none; outline: none;
transition: border-color 0.2s, box-shadow 0.2s; 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, input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder { textarea::placeholder {
font-family: var(--font-en); font-family: var(--font-ui);
opacity: 1; opacity: 1;
} }
[dir='rtl'], [dir='rtl'],
:lang(fa), :lang(fa),
.faText { .faText {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-weight: 400; /* IRANYekan Regular */ font-weight: 400; /* IRANYekan Regular for FA glyphs */
text-align: right; text-align: start;
} }
[dir='rtl']::placeholder, [dir='rtl']::placeholder,
@@ -142,7 +193,7 @@ textarea::placeholder {
input.faText::placeholder, input.faText::placeholder,
input[dir='rtl']::placeholder, input[dir='rtl']::placeholder,
input[lang='fa']::placeholder { input[lang='fa']::placeholder {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-weight: 400; font-weight: 400;
opacity: 1; 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 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 { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT'
export function AddNewProductPage() { export function AddNewProductPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const t = useT()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
@@ -183,12 +185,10 @@ export function AddNewProductPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}> <h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Product' : 'Add a New Product'} {isEdit ? t('title.editProduct') : t('products.card.new.title')}
</h2> </h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
{isEdit {isEdit ? t('products.form.edit.subtitle') : t('products.card.new.desc')}
? 'Update product details and save changes.'
: 'Create and publish a new product to your store.'}
</p> </p>
</div> </div>
</div> </div>
@@ -130,7 +130,7 @@
} }
.selectFieldFa { .selectFieldFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
direction: rtl; direction: rtl;
text-align: right; text-align: right;
} }
+144 -42
View File
@@ -1,105 +1,207 @@
import { useCallback, useEffect, useState } from 'react'
import { CalendarDays } from 'lucide-react' import { CalendarDays } from 'lucide-react'
import { import {
ShoppingBag, ShoppingBag,
Store, Store,
Users, Users,
Settings,
FileText, FileText,
Briefcase, Briefcase,
Globe, Globe,
} from 'lucide-react' } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { SectionCard } from '../components/SectionCard' 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' 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, icon: ShoppingBag,
title: 'Products', titleKey: 'home.card.products.title',
description: 'Manage your products, inventory and categories.', descKey: 'home.card.products.desc',
linkText: 'View products', linkKey: 'home.card.products.link',
countLabelKey: 'home.card.products.count',
href: '/products', href: '/products',
countKey: 'products',
}, },
{ {
icon: Store, icon: Store,
title: 'Store', titleKey: 'home.card.store.title',
description: 'Manage your store settings, pages and themes.', descKey: 'home.card.store.desc',
linkText: 'View store', linkKey: 'home.card.store.link',
countLabelKey: 'home.card.store.count',
href: '/store', href: '/store',
countKey: 'store',
}, },
{ {
icon: Users, icon: Users,
title: 'Customers', titleKey: 'home.card.customers.title',
description: 'View and manage your customers and their activity.', descKey: 'home.card.customers.desc',
linkText: 'View customers', linkKey: 'home.card.customers.link',
countLabelKey: 'home.card.customers.count',
href: '/customers', href: '/customers',
}, countKey: 'customers',
{
icon: Settings,
title: 'Settings',
description: 'Configure your store preferences and system settings.',
linkText: 'View settings',
href: '/settings',
}, },
{ {
icon: FileText, icon: FileText,
title: 'Blog', titleKey: 'home.card.blog.title',
description: 'Create and manage blog posts and categories.', descKey: 'home.card.blog.desc',
linkText: 'View blog', linkKey: 'home.card.blog.link',
countLabelKey: 'home.card.blog.count',
href: '/blog', href: '/blog',
countKey: 'blog',
}, },
{ {
icon: Briefcase, icon: Briefcase,
title: 'Portfolios', titleKey: 'home.card.portfolios.title',
description: 'Manage your portfolio items and showcase projects.', descKey: 'home.card.portfolios.desc',
linkText: 'View portfolios', linkKey: 'home.card.portfolios.link',
countLabelKey: 'home.card.portfolios.count',
href: '/portfolios', href: '/portfolios',
countKey: 'portfolios',
}, },
{ {
icon: Globe, icon: Globe,
title: 'Website', titleKey: 'home.card.website.title',
description: 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.', descKey: 'home.card.website.desc',
linkText: 'View website', linkKey: 'home.card.website.link',
href: '/website', href: '/website',
}, },
] ]
function getFormattedDate() { type SectionCounts = Partial<Record<CountKey, number>>
return new Intl.DateTimeFormat('en-US', {
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', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
weekday: 'long', weekday: 'long',
}).format(new Date()) }).format(new Date())
}
export function HomePage() {
const { user } = useAuth()
const firstName = user?.firstName || 'there'
return ( return (
<main className={styles.content}> <main className={styles.content}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}> <h2 className={styles.pageTitle}>{t('home.welcome', { name: firstName })}</h2>
Welcome back, {firstName}! <span aria-hidden="true">👋</span> <p className={styles.pageSubtitle}>{t('home.subtitle')}</p>
</h2>
<p className={styles.pageSubtitle}>
Here&apos;s what&apos;s happening with your store today.
</p>
</div> </div>
<div className={styles.dateBadge}> <div className={styles.dateBadge}>
<CalendarDays size={16} /> <CalendarDays size={16} />
<span>{getFormattedDate()}</span> <span>{formattedDate}</span>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{sections.map((section) => ( {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>
<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> </main>
) )
} }
+9 -4
View File
@@ -24,11 +24,15 @@
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 12px; gap: 12px;
margin-bottom: 28px; margin-bottom: 28px;
} }
.langSelect {
margin-inline-start: auto;
}
.logo { .logo {
display: block; display: block;
width: 48px; width: 48px;
@@ -112,7 +116,7 @@
.inputIcon { .inputIcon {
position: absolute; position: absolute;
left: 12px; inset-inline-start: 12px;
color: var(--text-muted); color: var(--text-muted);
pointer-events: none; pointer-events: none;
} }
@@ -120,7 +124,8 @@
.inputWrap input { .inputWrap input {
width: 100%; width: 100%;
min-height: var(--field-height); 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); font-size: var(--field-font-size);
line-height: 1.4; line-height: 1.4;
font-family: inherit; font-family: inherit;
@@ -140,7 +145,7 @@
.togglePassword { .togglePassword {
position: absolute; position: absolute;
right: 12px; inset-inline-end: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+78 -76
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react' 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 { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -13,6 +14,7 @@ import {
sendOtp, sendOtp,
verifyOtp, verifyOtp,
} from '../services/authService' } from '../services/authService'
import { useT } from '../i18n/useT'
import meshkeeLogo from '../assets/meshkee-logo.png' import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css' import styles from './LoginPage.module.css'
@@ -24,6 +26,7 @@ export function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
const { businessName, logoUrl } = useTenantBranding() const { businessName, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain() const businessDomain = getBusinessDomain()
const t = useT()
const [view, setView] = useState<AuthView>('login') const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone') const [smsStep, setSmsStep] = useState<SmsStep>('phone')
@@ -80,7 +83,11 @@ export function LoginPage() {
function handleApiError(err: unknown, fallback: string) { function handleApiError(err: unknown, fallback: string) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) if (err.message === BUSINESS_ACCESS_MESSAGE) {
setError(t('login.error.access'))
} else {
setError(err.message)
}
} else { } else {
setError(fallback) setError(fallback)
} }
@@ -102,7 +109,7 @@ export function LoginPage() {
setSmsStep('code') setSmsStep('code')
startCountdown() startCountdown()
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to send verification code.') handleApiError(err, t('login.error.sendCode'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -118,7 +125,7 @@ export function LoginPage() {
await login(cellNumber, password) await login(cellNumber, password)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to sign in. Check your connection and try again.') handleApiError(err, t('login.error.signIn'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -129,12 +136,12 @@ export function LoginPage() {
clearMessages() clearMessages()
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match.') setError(t('signup.error.match'))
return return
} }
if (password.length < 8) { if (password.length < 8) {
setError('Password must be at least 8 characters.') setError(t('signup.error.length'))
return return
} }
@@ -152,16 +159,14 @@ export function LoginPage() {
if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) { if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) {
logoutRequest() logoutRequest()
setError( setError(t('login.error.access'))
`${BUSINESS_ACCESS_MESSAGE} Customer registration on ${businessDomain} does not grant dashboard access.`,
)
return return
} }
setActiveBusiness(data.user) setActiveBusiness(data.user)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to create account.') handleApiError(err, t('signup.error.create'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -172,7 +177,7 @@ export function LoginPage() {
clearMessages() clearMessages()
if (newPassword.length < 8) { if (newPassword.length < 8) {
setError('Password must be at least 8 characters.') setError(t('forgot.error.length'))
return return
} }
@@ -181,12 +186,10 @@ export function LoginPage() {
try { try {
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode) await verifyOtp(cellNumber, smsCode)
setInfo( setInfo(t('forgot.info.partial'))
'Phone number verified. Full password reset via SMS is not available yet — please contact your administrator or sign in if you remember your password.',
)
setTimeout(() => switchView('login'), 2500) setTimeout(() => switchView('login'), 2500)
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to verify code.') handleApiError(err, t('forgot.error.verify'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -202,14 +205,14 @@ export function LoginPage() {
await verifyOtp(cellNumber, smsCode) await verifyOtp(cellNumber, smsCode)
if (!password) { if (!password) {
setError('Enter your account password to complete sign-in after SMS verification.') setError(t('otp.error.password'))
return return
} }
await login(cellNumber, password) await login(cellNumber, password)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to sign in with SMS verification.') handleApiError(err, t('otp.error.signIn'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -222,14 +225,17 @@ export function LoginPage() {
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} /> <img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.businessName}>{businessName || businessDomain}</span> <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>
</div> </div>
{view === 'login' && ( {view === 'login' && (
<> <>
<h1 className={styles.title}>Welcome back</h1> <h1 className={styles.title}>{t('login.welcome')}</h1>
<p className={styles.subtitle}>Sign in with your mobile number</p> <p className={styles.subtitle}>{t('login.subtitle')}</p>
<form className={styles.form} onSubmit={handleLogin}> <form className={styles.form} onSubmit={handleLogin}>
{error && ( {error && (
@@ -240,7 +246,7 @@ export function LoginPage() {
{info && <div className={styles.info}>{info}</div>} {info && <div className={styles.info}>{info}</div>}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="login-phone">Mobile number</label> <label htmlFor="login-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -257,13 +263,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="login-password">Password</label> <label htmlFor="login-password">{t('login.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="login-password" id="login-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Enter your password" placeholder={t('login.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -274,7 +280,7 @@ export function LoginPage() {
type="button" type="button"
className={styles.togglePassword} className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting} disabled={isSubmitting}
> >
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -289,17 +295,17 @@ export function LoginPage() {
onClick={() => switchView('forgot')} onClick={() => switchView('forgot')}
disabled={isSubmitting} disabled={isSubmitting}
> >
Forgot password? {t('login.forgot')}
</button> </button>
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'} {isSubmitting ? t('login.signingIn') : t('login.signIn')}
</button> </button>
</form> </form>
<div className={styles.divider}> <div className={styles.divider}>
<span>or</span> <span>{t('login.or')}</span>
</div> </div>
<button <button
@@ -309,18 +315,18 @@ export function LoginPage() {
disabled={isSubmitting} disabled={isSubmitting}
> >
<KeyRound size={18} /> <KeyRound size={18} />
One-time login with SMS {t('login.otp')}
</button> </button>
<p className={styles.footerText}> <p className={styles.footerText}>
Don&apos;t have an account?{' '} {t('login.noAccount')}{' '}
<button <button
type="button" type="button"
className={styles.linkBtn} className={styles.linkBtn}
onClick={() => switchView('signup')} onClick={() => switchView('signup')}
disabled={isSubmitting} disabled={isSubmitting}
> >
Sign up {t('login.signUp')}
</button> </button>
</p> </p>
</> </>
@@ -328,8 +334,8 @@ export function LoginPage() {
{view === 'signup' && ( {view === 'signup' && (
<> <>
<h1 className={styles.title}>Create account</h1> <h1 className={styles.title}>{t('signup.title')}</h1>
<p className={styles.subtitle}>Staff accounts are invited by the business owner</p> <p className={styles.subtitle}>{t('signup.subtitle', { domain: businessDomain })}</p>
<form className={styles.form} onSubmit={handleSignup}> <form className={styles.form} onSubmit={handleSignup}>
{error && ( {error && (
@@ -340,13 +346,13 @@ export function LoginPage() {
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-first">First name</label> <label htmlFor="signup-first">{t('signup.firstName')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} /> <User size={18} className={styles.inputIcon} />
<input <input
id="signup-first" id="signup-first"
type="text" type="text"
placeholder="First name" placeholder={t('signup.firstName')}
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
required required
@@ -356,13 +362,13 @@ export function LoginPage() {
</div> </div>
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-last">Last name</label> <label htmlFor="signup-last">{t('signup.lastName')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} /> <User size={18} className={styles.inputIcon} />
<input <input
id="signup-last" id="signup-last"
type="text" type="text"
placeholder="Last name" placeholder={t('signup.lastName')}
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
required required
@@ -374,7 +380,7 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-phone">Mobile number</label> <label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -391,13 +397,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-password">Password</label> <label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="signup-password" id="signup-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Choose a password" placeholder={t('signup.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -408,7 +414,7 @@ export function LoginPage() {
type="button" type="button"
className={styles.togglePassword} className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting} disabled={isSubmitting}
> >
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -417,13 +423,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-confirm">Confirm password</label> <label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="signup-confirm" id="signup-confirm"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Repeat your password" placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
required required
@@ -434,19 +440,19 @@ export function LoginPage() {
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create account'} {isSubmitting ? t('signup.creating') : t('signup.create')}
</button> </button>
</form> </form>
<p className={styles.footerText}> <p className={styles.footerText}>
Already have an account?{' '} {t('signup.hasAccount')}{' '}
<button <button
type="button" type="button"
className={styles.linkBtn} className={styles.linkBtn}
onClick={() => switchView('login')} onClick={() => switchView('login')}
disabled={isSubmitting} disabled={isSubmitting}
> >
Sign in {t('signup.signIn')}
</button> </button>
</p> </p>
</> </>
@@ -461,14 +467,12 @@ export function LoginPage() {
disabled={isSubmitting} disabled={isSubmitting}
> >
<ArrowLeft size={18} /> <ArrowLeft size={18} />
Back to sign in {t('forgot.back')}
</button> </button>
<h1 className={styles.title}>Forgot password</h1> <h1 className={styles.title}>{t('forgot.title')}</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
{smsStep === 'phone' {smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
? 'We will send a verification code via SMS'
: 'Enter the code and your new password'}
</p> </p>
<form className={styles.form} onSubmit={handleResetPassword}> <form className={styles.form} onSubmit={handleResetPassword}>
@@ -482,7 +486,7 @@ export function LoginPage() {
{smsStep === 'phone' ? ( {smsStep === 'phone' ? (
<> <>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="forgot-phone">Mobile number</label> <label htmlFor="forgot-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -504,19 +508,17 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? 'Sending...' : 'Send SMS code'} {isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
</button> </button>
</> </>
) : ( ) : (
<> <>
{codeSent && ( {codeSent && (
<p className={styles.codeHint}> <p className={styles.codeHint}>{t('common.codeSent', { phone })}</p>
Verification code sent to <strong>{phone}</strong>
</p>
)} )}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="forgot-code">SMS verification code</label> <label htmlFor="forgot-code">{t('forgot.code')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} /> <KeyRound size={18} className={styles.inputIcon} />
<input <input
@@ -534,13 +536,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <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}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="forgot-new-password" id="forgot-new-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Enter new password" placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
@@ -552,7 +554,9 @@ export function LoginPage() {
<div className={styles.resendRow}> <div className={styles.resendRow}>
{countdown > 0 ? ( {countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span> <span className={styles.countdown}>
{t('common.resendIn', { seconds: countdown })}
</span>
) : ( ) : (
<button <button
type="button" type="button"
@@ -560,13 +564,13 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
Resend SMS code {t('common.resend')}
</button> </button>
)} )}
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Verifying...' : 'Reset password'} {isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button> </button>
</> </>
)} )}
@@ -583,14 +587,12 @@ export function LoginPage() {
disabled={isSubmitting} disabled={isSubmitting}
> >
<ArrowLeft size={18} /> <ArrowLeft size={18} />
Back to sign in {t('otp.back')}
</button> </button>
<h1 className={styles.title}>One-time login</h1> <h1 className={styles.title}>{t('otp.title')}</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
{smsStep === 'phone' {smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
? 'Sign in with a one-time SMS code'
: 'Enter the SMS code and your password'}
</p> </p>
<form className={styles.form} onSubmit={handleOtpLogin}> <form className={styles.form} onSubmit={handleOtpLogin}>
@@ -603,7 +605,7 @@ export function LoginPage() {
{smsStep === 'phone' ? ( {smsStep === 'phone' ? (
<> <>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="otp-phone">Mobile number</label> <label htmlFor="otp-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -625,19 +627,17 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? 'Sending...' : 'Send SMS code'} {isSubmitting ? t('otp.sending') : t('otp.sendCode')}
</button> </button>
</> </>
) : ( ) : (
<> <>
{codeSent && ( {codeSent && (
<p className={styles.codeHint}> <p className={styles.codeHint}>{t('common.codeSent', { phone })}</p>
Verification code sent to <strong>{phone}</strong>
</p>
)} )}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="otp-code">SMS verification code</label> <label htmlFor="otp-code">{t('otp.code')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} /> <KeyRound size={18} className={styles.inputIcon} />
<input <input
@@ -655,13 +655,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="otp-password">Password</label> <label htmlFor="otp-password">{t('otp.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="otp-password" id="otp-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Your account password" placeholder={t('otp.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -673,7 +673,9 @@ export function LoginPage() {
<div className={styles.resendRow}> <div className={styles.resendRow}>
{countdown > 0 ? ( {countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span> <span className={styles.countdown}>
{t('common.resendIn', { seconds: countdown })}
</span>
) : ( ) : (
<button <button
type="button" type="button"
@@ -681,13 +683,13 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
Resend SMS code {t('common.resend')}
</button> </button>
)} )}
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'} {isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
</button> </button>
</> </>
)} )}
+43 -1
View File
@@ -12,6 +12,7 @@ import {
PORTFOLIOS_PER_PAGE, PORTFOLIOS_PER_PAGE,
deletePortfolio, deletePortfolio,
listPortfolios, listPortfolios,
updatePortfolio,
} from '../services/portfolioService' } from '../services/portfolioService'
import type { Portfolio } from '../types/portfolio' import type { Portfolio } from '../types/portfolio'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
@@ -25,6 +26,7 @@ export function PortfolioListPage() {
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [movingUpId, setMovingUpId] = useState<string | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({}) const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null) 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() { async function confirmDelete() {
if (!deleteTarget) return if (!deleteTarget) return
@@ -152,11 +191,14 @@ export function PortfolioListPage() {
) : ( ) : (
<> <>
<div className={pageStyles.gridCols4}> <div className={pageStyles.gridCols4}>
{portfolios.map((portfolio) => ( {portfolios.map((portfolio, index) => (
<PortfolioCard <PortfolioCard
key={portfolio.id} key={portfolio.id}
portfolio={portfolio} portfolio={portfolio}
commentCount={commentCounts[portfolio.id] ?? portfolio.commentCount} commentCount={commentCounts[portfolio.id] ?? portfolio.commentCount}
canMoveUp={!(index === 0 && currentPage === 1)}
isMovingUp={movingUpId === portfolio.id}
onMoveUp={handleMoveUp}
onEdit={handleEdit} onEdit={handleEdit}
onComments={handleComments} onComments={handleComments}
onRemove={handleRemoveRequest} onRemove={handleRemoveRequest}
@@ -139,7 +139,7 @@
} }
.description { .description {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 14px; font-size: 14px;
font-weight: 300; /* IRANYekan Light */ font-weight: 300; /* IRANYekan Light */
line-height: 1.7; line-height: 1.7;
@@ -149,7 +149,7 @@
.description:global(.faText), .description:global(.faText),
.description:global(.faText) :where(*) { .description:global(.faText) :where(*) {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-weight: 300; font-weight: 300;
text-align: justify; 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 { SectionCard } from '../components/SectionCard'
import { ProductActivityChart } from '../components/ProductActivityChart' import { ProductActivityChart } from '../components/ProductActivityChart'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
const productSections = [ const productSections: {
icon: typeof Package
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
href: string
}[] = [
{ {
icon: Package, icon: Package,
title: 'My Products', titleKey: 'nav.products.list',
description: 'View, edit and manage all your existing products.', descKey: 'products.card.list.desc',
linkText: 'View products',
href: '/products/list', href: '/products/list',
}, },
{ {
icon: PlusCircle, icon: PlusCircle,
title: 'Add a New Product', titleKey: 'products.card.new.title',
description: 'Create and publish a new product to your store.', descKey: 'products.card.new.desc',
linkText: 'Add product',
href: '/products/new', href: '/products/new',
}, },
{ {
icon: FolderTree, icon: FolderTree,
title: 'Categories', titleKey: 'nav.products.categories',
description: 'Organize your products into categories and subcategories.', descKey: 'products.card.categories.desc',
linkText: 'View categories',
href: '/products/categories', href: '/products/categories',
}, },
{ {
icon: Tag, icon: Tag,
title: 'Brands', titleKey: 'nav.products.brands',
description: 'Manage product brands and assign them when creating products.', descKey: 'products.card.brands.desc',
linkText: 'View brands',
href: '/products/brands', href: '/products/brands',
}, },
{ {
icon: Settings, icon: Settings,
title: 'Settings', titleKey: 'nav.products.settings',
description: 'Configure product defaults, variants and display options.', descKey: 'products.card.settings.desc',
linkText: 'View settings',
href: '/products/settings', href: '/products/settings',
}, },
] ]
export function ProductsPage() { export function ProductsPage() {
const t = useT()
return ( return (
<main className={styles.content}> <main className={styles.content}>
<Breadcrumbs <Breadcrumbs
@@ -53,16 +57,20 @@ export function ProductsPage() {
/> />
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}>Products</h2> <h2 className={styles.pageTitle}>{t('title.products')}</h2>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('products.overview.subtitle')}</p>
Manage your products, inventory and categories.
</p>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{productSections.map((section) => ( {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> </div>
@@ -55,6 +55,10 @@
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.stepInputFa {
font-family: var(--font-fa);
}
.stepInput:focus { .stepInput:focus {
outline: none; outline: none;
border-color: rgba(var(--primary-rgb) / 0.5); 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) => ({ return steps.map((step, index) => ({
id: step.id, id: step.id,
label: step.label.trim(), label: step.label.trim(),
labelFa: (step.labelFa ?? '').trim(),
color: normalizeStepColor(step.color, defaultStepColorForId(step.id, index)), color: normalizeStepColor(step.color, defaultStepColorForId(step.id, index)),
})) }))
} }
@@ -43,7 +44,10 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
return a.every((step, index) => { return a.every((step, index) => {
const other = b[index] const other = b[index]
return ( 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) { function updateStepColor(id: string, color: StepColorPreset) {
setDraftSteps((current) => setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, color } : step)), current.map((step) => (step.id === id ? { ...step, color } : step)),
@@ -123,6 +133,7 @@ export function StoreSettingsPage() {
{ {
id, id,
label: '', label: '',
labelFa: '',
color: defaultStepColorForId(id, current.length), color: defaultStepColorForId(id, current.length),
}, },
] ]
@@ -150,13 +161,13 @@ export function StoreSettingsPage() {
async function handleSaveSteps() { async function handleSaveSteps() {
const normalized = normalizeSteps(draftSteps) const normalized = normalizeSteps(draftSteps)
const hasEmptyLabel = normalized.some((step) => !step.label) const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
if (!normalized.length) { if (!normalized.length) {
setError('Add at least one order process step.') setError('Add at least one order process step.')
return return
} }
if (hasEmptyLabel) { if (hasEmptyLabel) {
setError('Every order step needs a label.') setError('Every order step needs an English and Farsi label.')
return return
} }
@@ -186,7 +197,9 @@ export function StoreSettingsPage() {
const canSaveSteps = const canSaveSteps =
stepsDirty && stepsDirty &&
draftSteps.length > 0 && 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 ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
@@ -270,10 +283,22 @@ export function StoreSettingsPage() {
type="text" type="text"
className={styles.stepInput} className={styles.stepInput}
value={step.label} value={step.label}
placeholder="Step label" placeholder="Label (EN)"
aria-label={`Order step ${index + 1}`} aria-label={`Order step ${index + 1} English label`}
dir="ltr"
lang="en"
onChange={(e) => updateStepLabel(step.id, e.target.value)} 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}> <div className={styles.stepControls}>
<Tooltip label="Move step up"> <Tooltip label="Move step up">
<button <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 status: OrderStatus
processStepId: string processStepId: string
processStepLabel?: string | null processStepLabel?: string | null
processStepLabelFa?: string | null
processStepColor?: string | null processStepColor?: string | null
source: OrderSource source: OrderSource
subtotal: number subtotal: number
@@ -105,6 +105,7 @@ export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
categoryName: portfolio.categoryName, categoryName: portfolio.categoryName,
tags: portfolio.tags, tags: portfolio.tags,
titleImageUrl: resolvePortfolioTitleImageUrl(portfolio), titleImageUrl: resolvePortfolioTitleImageUrl(portfolio),
sortOrder: portfolio.sortOrder ?? 0,
commentCount: portfolio.commentCount, commentCount: portfolio.commentCount,
publishedAt: portfolio.publishedAt, publishedAt: portfolio.publishedAt,
createdAt: portfolio.createdAt, createdAt: portfolio.createdAt,
+26 -4
View File
@@ -5,6 +5,7 @@ import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface BrandingSettings { export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId primaryColor: BusinessPrimaryColorId
defaultLocale?: 'en' | 'fa'
} }
export interface DashboardSettings { export interface DashboardSettings {
@@ -15,6 +16,7 @@ export interface DashboardSettings {
export interface OrderProcessStep { export interface OrderProcessStep {
id: string id: string
label: string label: string
labelFa: string
color: string color: string
} }
@@ -35,10 +37,30 @@ export interface SettingsResponse {
} }
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [ 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: 'processing',
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' }, label: 'Under processing',
{ id: 'delivered', label: 'Delivered', color: '#22C55E' }, 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() { function settingsPath() {
@@ -1,4 +1,5 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
export interface ResolvedTenant { export interface ResolvedTenant {
id: string id: string
@@ -6,6 +7,7 @@ export interface ResolvedTenant {
nameFa: string | null nameFa: string | null
slug: string slug: string
domain: string domain: string
defaultLocale?: DashboardLocale
logoUrl?: string | null logoUrl?: string | null
faviconUrl?: string | null faviconUrl?: string | null
} }
+2
View File
@@ -17,6 +17,8 @@ export interface AuthUser {
email: string | null email: string | null
firstName: string | null firstName: string | null
lastName: string | null lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
cellVerifiedAt: string | null cellVerifiedAt: string | null
roles: string[] roles: string[]
dashboard: DashboardType dashboard: DashboardType
+1
View File
@@ -17,6 +17,7 @@ export interface Portfolio {
categoryName: string categoryName: string
tags: string[] tags: string[]
titleImageUrl: string | null titleImageUrl: string | null
sortOrder: number
commentCount: number commentCount: number
publishedAt: string | null publishedAt: string | null
createdAt: string createdAt: string
@@ -11,6 +11,10 @@ const CSS_VAR_DEFAULTS: Record<string, string> = {
'--primary-dark': '#2563eb', '--primary-dark': '#2563eb',
'--primary-rgb': '59 130 246', '--primary-rgb': '59 130 246',
'--primary-dark-rgb': '37 99 235', '--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) { 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-dark', tokens.primaryDark)
root.style.setProperty('--primary-rgb', tokens.primaryRgb) root.style.setProperty('--primary-rgb', tokens.primaryRgb)
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb) 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() { export function resetBusinessPrimaryColor() {
@@ -20,8 +20,27 @@ export type BusinessPrimaryColorTokens = {
primaryGlow: string primaryGlow: string
primaryRgb: string primaryRgb: string
primaryDarkRgb: 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< export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId, BusinessPrimaryColorId,
BusinessPrimaryColorTokens BusinessPrimaryColorTokens
@@ -34,6 +53,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#ef4444', primaryGlow: '#ef4444',
primaryRgb: '239 68 68', primaryRgb: '239 68 68',
primaryDarkRgb: '220 38 38', primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
}, },
yellow: { yellow: {
label: 'Yellow', label: 'Yellow',
@@ -43,6 +63,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#eab308', primaryGlow: '#eab308',
primaryRgb: '234 179 8', primaryRgb: '234 179 8',
primaryDarkRgb: '202 138 4', primaryDarkRgb: '202 138 4',
...PURPLE_ACCENT,
}, },
black: { black: {
label: 'Black', label: 'Black',
@@ -52,6 +73,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#334155', primaryGlow: '#334155',
primaryRgb: '30 41 59', primaryRgb: '30 41 59',
primaryDarkRgb: '15 23 42', primaryDarkRgb: '15 23 42',
...CYAN_ACCENT,
}, },
cyan: { cyan: {
label: 'Cyan', label: 'Cyan',
@@ -61,6 +83,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#06b6d4', primaryGlow: '#06b6d4',
primaryRgb: '6 182 212', primaryRgb: '6 182 212',
primaryDarkRgb: '8 145 178', primaryDarkRgb: '8 145 178',
...PURPLE_ACCENT,
}, },
purple: { purple: {
label: 'Purple', label: 'Purple',
@@ -70,6 +93,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#a855f7', primaryGlow: '#a855f7',
primaryRgb: '168 85 247', primaryRgb: '168 85 247',
primaryDarkRgb: '147 51 234', primaryDarkRgb: '147 51 234',
...CYAN_ACCENT,
}, },
'light-blue': { 'light-blue': {
label: 'Light Blue', label: 'Light Blue',
@@ -79,6 +103,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#38bdf8', primaryGlow: '#38bdf8',
primaryRgb: '56 189 248', primaryRgb: '56 189 248',
primaryDarkRgb: '14 165 233', primaryDarkRgb: '14 165 233',
...CYAN_ACCENT,
}, },
'dark-blue': { 'dark-blue': {
label: 'Dark Blue', label: 'Dark Blue',
@@ -88,6 +113,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#3b82f6', primaryGlow: '#3b82f6',
primaryRgb: '59 130 246', primaryRgb: '59 130 246',
primaryDarkRgb: '37 99 235', primaryDarkRgb: '37 99 235',
...CYAN_ACCENT,
}, },
} }
+11 -4
View File
@@ -14,9 +14,13 @@ function toMonthKey(iso: string): string {
return `${year}-${month}` return `${year}-${month}`
} }
function formatMonthLabel(monthKey: string): string { function formatMonthLabel(monthKey: string, locale: string): string {
const [year, month] = monthKey.split('-').map(Number) 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[] { export function buildLast12MonthKeys(): string[] {
@@ -33,7 +37,10 @@ export function buildLast12MonthKeys(): string[] {
return keys return keys
} }
export function aggregateProductActivity(products: ProductApi[]): ProductMonthActivity[] { export function aggregateProductActivity(
products: ProductApi[],
locale: string = 'en',
): ProductMonthActivity[] {
const monthKeys = buildLast12MonthKeys() const monthKeys = buildLast12MonthKeys()
const added = new Map(monthKeys.map((key) => [key, 0])) const added = new Map(monthKeys.map((key) => [key, 0]))
const updated = 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) => ({ return monthKeys.map((monthKey) => ({
monthKey, monthKey,
label: formatMonthLabel(monthKey), label: formatMonthLabel(monthKey, locale),
added: added.get(monthKey) ?? 0, added: added.get(monthKey) ?? 0,
updated: updated.get(monthKey) ?? 0, updated: updated.get(monthKey) ?? 0,
})) }))
+13 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link <link
@@ -15,6 +15,18 @@
rel="stylesheet" rel="stylesheet"
/> />
<title>Customer Dashboard</title> <title>Customer Dashboard</title>
<script>
try {
var l = localStorage.getItem('meshkee.dashboard.locale')
if (l === 'en') {
document.documentElement.lang = 'en'
document.documentElement.dir = 'ltr'
} else {
document.documentElement.lang = 'fa'
document.documentElement.dir = 'rtl'
}
} catch (e) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -1
View File
@@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
import { CustomerThemeProvider } from './context/CustomerThemeContext' import { CustomerThemeProvider } from './context/CustomerThemeContext'
import { TenantBrandingProvider } from './context/TenantBrandingContext' import { TenantBrandingProvider } from './context/TenantBrandingContext'
import { ToastProvider } from '@meshkee/dashboard-ui' import { LocaleProvider, ToastProvider } from '@meshkee/dashboard-ui'
import { CustomerDomainGuard } from './components/CustomerDomainGuard' import { CustomerDomainGuard } from './components/CustomerDomainGuard'
import { DashboardDocumentTitle } from './components/DashboardDocumentTitle' import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
@@ -26,6 +26,7 @@ import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep'
function App() { function App() {
return ( return (
<CustomerDomainGuard> <CustomerDomainGuard>
<LocaleProvider>
<CustomerThemeProvider> <CustomerThemeProvider>
<BrowserRouter> <BrowserRouter>
<TenantBrandingProvider> <TenantBrandingProvider>
@@ -64,6 +65,7 @@ function App() {
</TenantBrandingProvider> </TenantBrandingProvider>
</BrowserRouter> </BrowserRouter>
</CustomerThemeProvider> </CustomerThemeProvider>
</LocaleProvider>
</CustomerDomainGuard> </CustomerDomainGuard>
) )
} }
@@ -0,0 +1,15 @@
.modal {
max-width: 720px;
}
.overlay {
z-index: 1000;
}
.modal :global(select) {
background-position: right var(--select-arrow-offset) center;
}
:global([dir='rtl']) .modal :global(select) {
background-position: left var(--select-arrow-offset) center;
}
@@ -0,0 +1,357 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import {
getLocationOptionLabel,
matchCityByName,
matchProvinceByName,
useLocale,
useToast,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createAddress,
updateAddress,
type UserAddress,
} from '../services/addressService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
type CityOption,
} from '../services/citiesService'
import modalStyles from './VariationsModal.module.css'
import panelStyles from './checkout/CheckoutAddAddressPanel.module.css'
import styles from './AddressFormModal.module.css'
interface AddressFormModalProps {
open: boolean
address: UserAddress | null
onClose: () => void
onSaved: (address: UserAddress) => void
}
const ANIMATION_MS = 220
export function AddressFormModal({ open, address, onClose, onSaved }: AddressFormModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [formKey, setFormKey] = useState(0)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setFormKey((key) => key + 1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
const isEdit = Boolean(address?.id)
return createPortal(
<div
className={`${modalStyles.overlay} ${styles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="address-form-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="address-form-title" className={modalStyles.title}>
{isEdit ? t('addresses.modal.editTitle') : t('addresses.modal.addTitle')}
</h2>
<p className={modalStyles.subtitle}>{t('addresses.modal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
<AddressFormFields
key={formKey}
address={address}
onCancel={onClose}
onSaved={(saved) => {
showToast(
isEdit ? t('addresses.toast.updated') : t('addresses.toast.created'),
'success',
)
onSaved(saved)
onClose()
}}
/>
</div>
</div>
</div>,
document.body,
)
}
function AddressFormFields({
address,
onCancel,
onSaved,
}: {
address: UserAddress | null
onCancel: () => void
onSaved: (address: UserAddress) => void
}) {
const t = useT()
const { locale } = useLocale()
const [provinces, setProvinces] = useState<CityOption[]>([])
const [cities, setCities] = useState<CityOption[]>([])
const [loadingLocations, setLoadingLocations] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [label, setLabel] = useState(address?.label ?? '')
const [provinceSlug, setProvinceSlug] = useState('')
const [city, setCity] = useState('')
const [street, setStreet] = useState(address?.address ?? '')
const [postalCode, setPostalCode] = useState(address?.postalCode ?? '')
const [landline, setLandline] = useState(address?.landline ?? '')
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoadingLocations(true)
try {
const items = await listIranProvinces(controller.signal)
if (controller.signal.aborted) return
setProvinces(items)
if (!address) return
const province = matchProvinceByName(address.province, items)
if (!province) return
setProvinceSlug(province.slug)
const cityItems = await listCitiesByProvinceSlug(province.slug, controller.signal)
if (controller.signal.aborted) return
setCities(cityItems)
const matchedCity = matchCityByName(address.city, cityItems)
setCity(
matchedCity ? getLocationOptionLabel(matchedCity, locale) : address.city,
)
} catch {
if (!controller.signal.aborted) setError(t('addresses.error.loadLocations'))
} finally {
if (!controller.signal.aborted) setLoadingLocations(false)
}
}
void load()
return () => controller.abort()
}, [address, locale, t])
async function handleProvinceChange(slug: string) {
setProvinceSlug(slug)
setCity('')
setCities([])
if (!slug) return
try {
const items = await listCitiesByProvinceSlug(slug)
setCities(items)
} catch {
setError(t('addresses.error.loadCities'))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const province = provinces.find((item) => item.slug === provinceSlug)
if (!label.trim() || !province || !city.trim() || !street.trim()) {
setError(t('addresses.error.incompleteForm'))
return
}
const payload = {
label: label.trim(),
province: getLocationOptionLabel(province, locale),
city: city.trim(),
address: street.trim(),
postalCode: postalCode.trim() || undefined,
landline: landline.trim() || undefined,
}
setSaving(true)
try {
const result = address?.id
? await updateAddress(address.id, payload)
: await createAddress(payload)
onSaved(result.address)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('addresses.error.save'))
} finally {
setSaving(false)
}
}
return (
<form
className={[panelStyles.form, panelStyles.formInModal].join(' ')}
onSubmit={(e) => void handleSubmit(e)}
>
{error && (
<div className={panelStyles.error} role="alert">
{error}
</div>
)}
<div className={panelStyles.fieldRowTriple}>
<div className={panelStyles.field}>
<label htmlFor="address-label">{t('addresses.label')}</label>
<input
id="address-label"
type="text"
value={label}
disabled={saving}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('addresses.labelPlaceholder')}
/>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-province">{t('addresses.province')}</label>
<select
id="address-province"
value={provinceSlug}
disabled={loadingLocations || saving}
onChange={(e) => void handleProvinceChange(e.target.value)}
>
<option value="">{t('addresses.selectProvince')}</option>
{provinces.map((province) => (
<option key={province.id} value={province.slug}>
{getLocationOptionLabel(province, locale)}
</option>
))}
</select>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-city">{t('addresses.city')}</label>
<select
id="address-city"
value={city}
disabled={!provinceSlug || saving}
onChange={(e) => setCity(e.target.value)}
>
<option value="">{t('addresses.selectCity')}</option>
{cities.map((item) => (
<option key={item.id} value={getLocationOptionLabel(item, locale)}>
{getLocationOptionLabel(item, locale)}
</option>
))}
</select>
</div>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-street">{t('addresses.address')}</label>
<input
id="address-street"
type="text"
value={street}
disabled={saving}
onChange={(e) => setStreet(e.target.value)}
placeholder={t('addresses.streetPlaceholder')}
/>
</div>
<div className={panelStyles.fieldRow}>
<div className={panelStyles.field}>
<label htmlFor="address-postal">
{t('addresses.postalCode')}
<span className={panelStyles.optionalMark}> {t('addresses.optional')}</span>
</label>
<input
id="address-postal"
type="text"
inputMode="numeric"
value={postalCode}
disabled={saving}
onChange={(e) => setPostalCode(e.target.value)}
placeholder={t('addresses.postalPlaceholder')}
dir="ltr"
/>
</div>
<div className={panelStyles.field}>
<label htmlFor="address-landline">
{t('addresses.landline')}
<span className={panelStyles.optionalMark}> {t('addresses.optional')}</span>
</label>
<input
id="address-landline"
type="tel"
value={landline}
disabled={saving}
onChange={(e) => setLandline(e.target.value)}
placeholder={t('addresses.landlinePlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={panelStyles.formActions}>
<button
type="button"
className={panelStyles.cancelBtn}
onClick={onCancel}
disabled={saving}
>
{t('addresses.cancel')}
</button>
<button type="submit" className={panelStyles.saveBtn} disabled={saving}>
{saving ? t('addresses.saving') : t('addresses.saveOne')}
</button>
</div>
</form>
)
}
@@ -1,17 +1,18 @@
import { useLocation } from 'react-router-dom' 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 { useTenantBranding } from '../context/TenantBrandingContext'
import { CUSTOMER_DASHBOARD_NAME, customerRouteTitleRules } from '../lib/routeTitles' import { getCustomerRouteTitleRules, translate } from '../i18n/messages'
export function DashboardDocumentTitle() { export function DashboardDocumentTitle() {
const { pathname } = useLocation() const { pathname } = useLocation()
const { businessName } = useTenantBranding() const { businessName } = useTenantBranding()
const { locale } = useLocale()
useDashboardDocumentTitle({ useDashboardDocumentTitle({
businessName, businessName,
dashboardName: CUSTOMER_DASHBOARD_NAME, dashboardName: translate(locale, 'app.dashboardName'),
pathname, pathname,
routeRules: customerRouteTitleRules, routeRules: getCustomerRouteTitleRules(locale),
}) })
return null return null
@@ -62,13 +62,13 @@
} }
.festivalBadge { .festivalBadge {
left: 8px; inset-inline-start: 8px;
text-transform: uppercase; text-transform: uppercase;
color: #7c3aed; color: #7c3aed;
} }
.stockBadge { .stockBadge {
right: 8px; inset-inline-end: 8px;
color: var(--primary); color: var(--primary);
} }
@@ -83,7 +83,7 @@
color: var(--text-primary); color: var(--text-primary);
line-height: 1.3; line-height: 1.3;
margin-bottom: 3px; margin-bottom: 3px;
text-align: left; text-align: start;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -91,7 +91,7 @@
} }
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
direction: rtl; direction: rtl;
@@ -129,7 +129,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
padding-left: 4px; padding-inline-start: 4px;
} }
.controlsLeft button { .controlsLeft button {
@@ -1,6 +1,8 @@
import { useId } from 'react' import { useId } from 'react'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { FavoriteListing } from '../services/favoritesService' import type { FavoriteListing } from '../services/favoritesService'
import { useT } from '../i18n/useT'
import { formatVariantCount } from '../utils/storeProductGroups' import { formatVariantCount } from '../utils/storeProductGroups'
import { StoreItemPrice } from './StoreItemPrice' import { StoreItemPrice } from './StoreItemPrice'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
@@ -55,6 +57,8 @@ export function FavoriteStoreItemCard({
onAddToCart, onAddToCart,
removing = false, removing = false,
}: FavoriteStoreItemCardProps) { }: FavoriteStoreItemCardProps) {
const t = useT()
const { locale } = useLocale()
const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}` const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}`
return ( return (
@@ -71,16 +75,20 @@ export function FavoriteStoreItemCard({
) : ( ) : (
<div className={styles.imagePlaceholder} /> <div className={styles.imagePlaceholder} />
)} )}
{listing.showFestival && <span className={styles.festivalBadge}>Festival</span>} {listing.showFestival && (
<span className={styles.festivalBadge}>{t('favorites.festival')}</span>
)}
{listing.productTotalStock > 0 && ( {listing.productTotalStock > 0 && (
<span className={styles.stockBadge}>{listing.productTotalStock} in stock</span> <span className={styles.stockBadge}>
{t('favorites.inStock', { count: listing.productTotalStock })}
</span>
)} )}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
<h3 className={styles.nameEn}>{listing.productTitle}</h3> <h3 className={styles.nameEn}>{listing.productTitle}</h3>
{listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>} {listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>}
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount)}</p> <p className={styles.variantLabel}>{formatVariantCount(listing.variantCount, locale)}</p>
<StoreItemPrice <StoreItemPrice
price={listing.displayPrice} price={listing.displayPrice}
discountedPrice={listing.displayDiscountedPrice} discountedPrice={listing.displayDiscountedPrice}
@@ -90,25 +98,25 @@ export function FavoriteStoreItemCard({
<div className={styles.controls}> <div className={styles.controls}>
<div className={styles.controlsLeft}> <div className={styles.controlsLeft}>
<Tooltip label="Remove from favorites"> <Tooltip label={t('favorites.remove')}>
<button <button
type="button" type="button"
className={styles.danger} className={styles.danger}
onClick={() => onRemove(listing)} onClick={() => onRemove(listing)}
disabled={removing} disabled={removing}
aria-label="Remove from favorites" aria-label={t('favorites.remove')}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<Tooltip label="Add to shopping cart"> <Tooltip label={t('favorites.addToCart')}>
<button <button
type="button" type="button"
className={styles.addToCartBtn} className={styles.addToCartBtn}
onClick={() => onAddToCart(listing)} onClick={() => onAddToCart(listing)}
aria-label="Add to shopping cart" aria-label={t('favorites.addToCart')}
> >
<GradientPlusIcon gradientId={plusGradientId} /> <GradientPlusIcon gradientId={plusGradientId} />
</button> </button>
+9 -13
View File
@@ -60,9 +60,10 @@
.badge { .badge {
position: absolute; position: absolute;
top: 4px; top: 4px;
right: 4px; inset-inline-end: 4px;
width: 18px; min-width: 18px;
height: 18px; height: 18px;
padding: 0 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -70,7 +71,8 @@
color: white; color: white;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
border-radius: 50%; line-height: 1;
border-radius: 999px;
border: 2px solid white; border: 2px solid white;
} }
@@ -81,8 +83,9 @@
.profile { .profile {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 6px 12px 6px 6px; padding-block: 8px;
padding-inline: 14px 12px;
border-radius: 50px; border-radius: 50px;
background: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
@@ -96,13 +99,6 @@
border-color: rgba(var(--primary-rgb) / 0.25); border-color: rgba(var(--primary-rgb) / 0.25);
} }
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo { .profileInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -132,7 +128,7 @@
.dropdown { .dropdown {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; inset-inline-end: 0;
min-width: 180px; min-width: 180px;
padding: 6px; padding: 6px;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
+40 -16
View File
@@ -1,20 +1,45 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react' import { Menu, Bell, MessageSquare, ChevronDown, User, 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 { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { changePassword } from '../services/authService' import { changePassword } from '../services/authService'
import styles from './Header.module.css' import styles from './Header.module.css'
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() { export function Header() {
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false) const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const displayName = const displayName = displayUserName(user, locale, t('app.role.customer'))
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.cellNumber || 'Customer'
useEffect(() => { useEffect(() => {
if (!menuOpen) return if (!menuOpen) return
@@ -52,19 +77,22 @@ export function Header() {
<> <>
<header className={styles.header}> <header className={styles.header}>
<div className={styles.left}> <div className={styles.left}>
<button className={styles.menuBtn} aria-label="Toggle menu"> <button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} /> <Menu size={22} />
</button> </button>
<h1 className={styles.title}>Customer Dashboard</h1>
</div> </div>
<div className={styles.right}> <div className={styles.right}>
<button className={styles.iconBtn} aria-label="Messages"> <LanguageSelect />
<button className={styles.iconBtn} aria-label={t('header.messages')}>
<MessageSquare size={20} /> <MessageSquare size={20} />
<span className={styles.badge}>0</span>
</button> </button>
<button className={styles.iconBtn} aria-label="Notifications"> <button className={styles.iconBtn} aria-label={t('header.notifications')}>
<Bell size={20} /> <Bell size={20} />
<span className={styles.badge}>0</span>
</button> </button>
<div className={styles.profileWrap} ref={menuRef}> <div className={styles.profileWrap} ref={menuRef}>
@@ -75,14 +103,9 @@ export function Header() {
aria-expanded={menuOpen} aria-expanded={menuOpen}
aria-haspopup="menu" 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}> <div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span> <span className={styles.name}>{displayName}</span>
<span className={styles.role}>Customer</span> <span className={styles.role}>{t('app.role.customer')}</span>
</div> </div>
<ChevronDown <ChevronDown
size={16} size={16}
@@ -99,7 +122,7 @@ export function Header() {
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
> >
<User size={16} /> <User size={16} />
<span>My Profile</span> <span>{t('header.myProfile')}</span>
</Link> </Link>
<button <button
type="button" type="button"
@@ -108,7 +131,7 @@ export function Header() {
onClick={openPasswordModal} onClick={openPasswordModal}
> >
<KeyRound size={16} /> <KeyRound size={16} />
<span>Change password</span> <span>{t('header.changePassword')}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -117,7 +140,7 @@ export function Header() {
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut size={16} /> <LogOut size={16} />
<span>Logout</span> <span>{t('nav.logout')}</span>
</button> </button>
</div> </div>
)} )}
@@ -129,6 +152,7 @@ export function Header() {
open={passwordModalOpen} open={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)} onClose={() => setPasswordModalOpen(false)}
onChangePassword={changePassword} onChangePassword={changePassword}
title={t('header.changePassword')}
/> />
</> </>
) )
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { Order } from '../services/orderService' import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
@@ -30,6 +31,7 @@ function totalQuantity(order: Order) {
} }
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) { export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -75,11 +77,16 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h2 id="order-items-title" className={modalStyles.title}> <h2 id="order-items-title" className={modalStyles.title}>
Order items {t('orderItems.title')}
</h2> </h2>
<p className={modalStyles.subtitle}>{order.orderNumber}</p> <p className={modalStyles.subtitle}>{order.orderNumber}</p>
</div> </div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
@@ -87,26 +94,27 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={`${modalStyles.body} ${styles.body}`}> <div className={`${modalStyles.body} ${styles.body}`}>
<div className={styles.metaRow}> <div className={styles.metaRow}>
<span className={styles.metaItem}> <span className={styles.metaItem}>
Customer: <strong>{displayName(order)}</strong> {t('orderItems.customer')}: <strong>{displayName(order)}</strong>
</span> </span>
<span className={styles.metaItem}> <span className={styles.metaItem}>
Phone: <strong>{formatCellForDisplay(order.customer.cellNumber)}</strong> {t('orderItems.phone')}:{' '}
<strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
</span> </span>
<span className={styles.metaItem}> <span className={styles.metaItem}>
Items: <strong>{itemCount}</strong> {t('orderItems.items')}: <strong>{itemCount}</strong>
</span> </span>
</div> </div>
{order.items.length === 0 ? ( {order.items.length === 0 ? (
<p className={styles.empty}>No items in this order.</p> <p className={styles.empty}>{t('orderItems.empty')}</p>
) : ( ) : (
<div className={styles.tableBlock}> <div className={styles.tableBlock}>
<div className={styles.tableHead}> <div className={styles.tableHead}>
<span aria-hidden="true" /> <span aria-hidden="true" />
<span>Product</span> <span>{t('orderItems.product')}</span>
<span>Qty</span> <span>{t('orderItems.qty')}</span>
<span>Unit price</span> <span>{t('orderItems.unitPrice')}</span>
<span>Line total</span> <span>{t('orderItems.lineTotal')}</span>
</div> </div>
<ul className={styles.itemList}> <ul className={styles.itemList}>
{order.items.map((item) => ( {order.items.map((item) => (
@@ -122,7 +130,9 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.itemTitle}>{item.productTitle}</div> <div className={styles.itemTitle}>{item.productTitle}</div>
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div> <div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
{item.variantSku && ( {item.variantSku && (
<div className={styles.itemSku}>SKU: {item.variantSku}</div> <div className={styles.itemSku}>
{t('orderItems.sku', { sku: item.variantSku })}
</div>
)} )}
</div> </div>
<div className={styles.qtyCell}>{item.quantity}</div> <div className={styles.qtyCell}>{item.quantity}</div>
@@ -136,20 +146,23 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.summary}> <div className={styles.summary}>
<span className={styles.summaryLabel}> <span className={styles.summaryLabel}>
Order total · {itemCount} {itemCount === 1 ? 'item' : 'items'} {t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span> </span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span> <span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
</div> </div>
<div className={modalStyles.actions}> <div className={modalStyles.actions}>
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}> <button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
Close {t('orderItems.close')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
, document.body,
document.body,
) )
} }
@@ -1,6 +1,6 @@
.td { .td {
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16); border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle; vertical-align: middle;
font-size: 13px; font-size: 13px;
@@ -75,16 +75,17 @@
} }
.tdActions { .tdActions {
text-align: right; text-align: end;
white-space: nowrap; white-space: nowrap;
padding-right: 10px; padding-inline: 10px;
} }
.rowActions { .rowActions {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0; gap: 0;
width: 100%;
} }
.actionBtn { .actionBtn {
+42 -31
View File
@@ -1,4 +1,6 @@
import { Eye } from 'lucide-react' import { Eye } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Order, OrderSource } from '../services/orderService' import type { Order, OrderSource } from '../services/orderService'
import type { OrderProcessStep } from '../utils/orderSteps' import type { OrderProcessStep } from '../utils/orderSteps'
import { stepLabel, stepColor } from '../utils/orderSteps' import { stepLabel, stepColor } from '../utils/orderSteps'
@@ -12,12 +14,13 @@ interface OrderRowProps {
onViewItems: (order: Order) => void onViewItems: (order: Order) => void
} }
function formatDateTime(value: string) { function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return { return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }), date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
} }
} }
@@ -25,35 +28,37 @@ function totalItemQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0) return order.items.reduce((sum, item) => sum + item.quantity, 0)
} }
function sourceLabel(source: OrderSource) {
switch (source) {
case 'admin':
return 'Operator'
case 'app':
return 'Application'
case 'website':
default:
return 'Website'
}
}
function sourceClass(source: OrderSource) {
switch (source) {
case 'admin':
return styles.sourceOperator
case 'app':
return styles.sourceApplication
case 'website':
default:
return styles.sourceWebsite
}
}
export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) { export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
const { date, time } = formatDateTime(order.createdAt) const { locale } = useLocale()
const t = useT()
const { date, time } = formatDateTime(order.createdAt, locale)
const itemQty = totalItemQuantity(order) const itemQty = totalItemQuantity(order)
const processStepId = order.processStepId ?? processSteps[0]?.id ?? 'processing' const processStepId = order.processStepId ?? processSteps[0]?.id ?? 'processing'
function sourceLabel(source: OrderSource) {
switch (source) {
case 'admin':
return t('orders.source.operator')
case 'app':
return t('orders.source.app')
case 'website':
default:
return t('orders.source.website')
}
}
function sourceClass(source: OrderSource) {
switch (source) {
case 'admin':
return styles.sourceOperator
case 'app':
return styles.sourceApplication
case 'website':
default:
return styles.sourceWebsite
}
}
return ( return (
<tr> <tr>
<td className={styles.td}> <td className={styles.td}>
@@ -74,7 +79,13 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
stepColor(processSteps, processStepId, order.processStepColor), stepColor(processSteps, processStepId, order.processStepColor),
)} )}
> >
{stepLabel(processSteps, processStepId, order.processStepLabel)} {stepLabel(
processSteps,
processStepId,
order.processStepLabel,
order.processStepLabelFa,
locale,
)}
</span> </span>
</td> </td>
<td className={styles.td}> <td className={styles.td}>
@@ -88,8 +99,8 @@ export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
type="button" type="button"
className={styles.actionBtn} className={styles.actionBtn}
onClick={() => onViewItems(order)} onClick={() => onViewItems(order)}
aria-label="View items" aria-label={t('orders.viewItems')}
title="View items" title={t('orders.viewItems')}
> >
<Eye size={15} /> <Eye size={15} />
</button> </button>
@@ -4,7 +4,7 @@
} }
.main { .main {
margin-left: var(--sidebar-width); margin-inline-start: var(--sidebar-width);
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -12,6 +12,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.main { .main {
margin-left: 0; margin-inline-start: 0;
} }
} }
+16 -14
View File
@@ -1,7 +1,7 @@
.sidebar { .sidebar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; inset-inline-start: 0;
width: var(--sidebar-width); width: var(--sidebar-width);
height: 100vh; height: 100vh;
display: flex; display: flex;
@@ -10,7 +10,7 @@
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-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; z-index: 100;
} }
@@ -61,17 +61,18 @@
} }
.brandDomain { .brandDomain {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.brandName {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
line-height: 1.2;
}
.brandName {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
} }
.nav { .nav {
@@ -80,7 +81,7 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
overflow-y: auto; overflow-y: auto;
padding-right: 2px; padding-inline-end: 2px;
} }
.navGroup { .navGroup {
@@ -99,7 +100,7 @@
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.2s ease; transition: all 0.2s ease;
width: 100%; width: 100%;
text-align: left; text-align: start;
} }
.navItem:hover { .navItem:hover {
@@ -134,9 +135,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
margin: 2px 0 4px 12px; margin-block: 2px 4px;
padding-left: 12px; margin-inline: 12px 0;
border-left: 2px solid rgba(148, 163, 184, 0.2); padding-inline-start: 12px;
border-inline-start: 2px solid rgba(148, 163, 184, 0.2);
} }
.subNavItem { .subNavItem {
+24 -17
View File
@@ -1,32 +1,35 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom' import { NavLink, useNavigate } from 'react-router-dom'
import { Home, User, MapPin, ShoppingBag, ShoppingCart, Heart, HelpCircle, LogOut } from 'lucide-react' import { Home, User, MapPin, ShoppingBag, Heart, HelpCircle, LogOut } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessDomain } from '../lib/businessContext' import { getActiveBusinessDomain } from '../lib/businessContext'
import { isAbortError } from '../lib/api' import { isAbortError } from '../lib/api'
import { getWebsiteBusinessInfo } from '../services/websiteService' import { getWebsiteBusinessInfo } from '../services/websiteService'
import meshkeeLogo from '../assets/meshkee-logo.png' import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
const navItems = [
{ icon: Home, label: 'Home', to: '/' },
{ icon: ShoppingCart, label: 'Shopping Cart', to: '/checkout' },
{ icon: User, label: 'My Profile', to: '/profile' },
{ icon: MapPin, label: 'My Addresses', to: '/addresses' },
{ icon: ShoppingBag, label: 'My Orders', to: '/orders' },
{ icon: Heart, label: 'My Favorites', to: '/favorites' },
]
export function Sidebar() { export function Sidebar() {
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { locale } = useLocale()
const t = useT()
const [brandName, setBrandName] = useState('') const [brandName, setBrandName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null) const [logoUrl, setLogoUrl] = useState<string | null>(null)
const businessDomain = getActiveBusinessDomain() const businessDomain = getActiveBusinessDomain()
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? 'Store' const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
const displayName = brandName || fallbackBusinessName const displayName = brandName || fallbackBusinessName
const navItems = [
{ icon: Home, label: t('nav.home'), to: '/' },
{ icon: User, label: t('nav.profile'), to: '/profile' },
{ icon: MapPin, label: t('nav.addresses'), to: '/addresses' },
{ icon: ShoppingBag, label: t('nav.orders'), to: '/orders' },
{ icon: Heart, label: t('nav.favorites'), to: '/favorites' },
]
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -34,7 +37,11 @@ export function Sidebar() {
try { try {
const info = await getWebsiteBusinessInfo(businessDomain, controller.signal) const info = await getWebsiteBusinessInfo(businessDomain, controller.signal)
if (controller.signal.aborted) return if (controller.signal.aborted) return
setBrandName(info.nameFa?.trim() || info.name.trim() || fallbackBusinessName) const localized =
locale === 'fa'
? info.nameFa?.trim() || info.name.trim()
: info.name.trim() || info.nameFa?.trim()
setBrandName(localized || fallbackBusinessName)
setLogoUrl(info.logoUrl?.trim() || null) setLogoUrl(info.logoUrl?.trim() || null)
} catch (err) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err)) return
@@ -48,7 +55,7 @@ export function Sidebar() {
return () => { return () => {
controller.abort() controller.abort()
} }
}, [businessDomain, fallbackBusinessName]) }, [businessDomain, fallbackBusinessName, locale])
return ( return (
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
@@ -59,15 +66,15 @@ export function Sidebar() {
className={styles.brandLogo} className={styles.brandLogo}
/> />
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.brandDomain}>{businessDomain}</span>
<span className={styles.brandName}>{displayName}</span> <span className={styles.brandName}>{displayName}</span>
<span className={styles.brandDomain}>{businessDomain}</span>
</div> </div>
</div> </div>
<nav className={styles.nav}> <nav className={styles.nav}>
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.label} key={item.to}
to={item.to} to={item.to}
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => className={({ isActive }) =>
@@ -83,7 +90,7 @@ export function Sidebar() {
<div className={styles.footer}> <div className={styles.footer}>
<button type="button" className={styles.navItem}> <button type="button" className={styles.navItem}>
<HelpCircle size={20} /> <HelpCircle size={20} />
<span>Help Center</span> <span>{t('nav.help')}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -94,7 +101,7 @@ export function Sidebar() {
}} }}
> >
<LogOut size={20} /> <LogOut size={20} />
<span>Logout</span> <span>{t('nav.logout')}</span>
</button> </button>
</div> </div>
</aside> </aside>
@@ -17,3 +17,7 @@
.backBtn span { .backBtn span {
line-height: 1.4; line-height: 1.4;
} }
:global([dir='rtl']) .backBtn svg {
transform: scaleX(-1);
}
@@ -3,10 +3,12 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { applyDocumentFavicon } from '@meshkee/dashboard-core' import { applyDocumentFavicon } from '@meshkee/dashboard-core'
import { useLocale } from '@meshkee/dashboard-ui'
import { isAbortError } from '../lib/api' import { isAbortError } from '../lib/api'
import { getTenantDomain } from '../lib/config' import { getTenantDomain } from '../lib/config'
import { getWebsiteBusinessInfo } from '../services/websiteService' import { getWebsiteBusinessInfo } from '../services/websiteService'
@@ -31,9 +33,11 @@ function pickBusinessName(
} }
export function TenantBrandingProvider({ children }: { children: ReactNode }) { export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const { setLocale } = useLocale()
const [businessName, setBusinessName] = useState('') const [businessName, setBusinessName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null) const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null) const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const defaultLocaleAppliedRef = useRef(false)
const domain = getTenantDomain() const domain = getTenantDomain()
useEffect(() => { useEffect(() => {
@@ -68,6 +72,15 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setLogoUrl(nextLogo) setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon) setFaviconUrl(nextFavicon)
applyDocumentFavicon(nextFavicon) applyDocumentFavicon(nextFavicon)
if (!defaultLocaleAppliedRef.current) {
defaultLocaleAppliedRef.current = true
if (tenant.defaultLocale === 'en' || tenant.defaultLocale === 'fa') {
setLocale(tenant.defaultLocale)
} else {
setLocale('fa')
}
}
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return if (isAbortError(err) || controller.signal.aborted) return
setBusinessName(domain) setBusinessName(domain)
@@ -82,7 +95,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
return () => { return () => {
controller.abort() controller.abort()
} }
}, [domain]) }, [domain, setLocale])
const value = useMemo( const value = useMemo(
() => ({ businessName, logoUrl, faviconUrl }), () => ({ businessName, logoUrl, faviconUrl }),
+506
View File
@@ -0,0 +1,506 @@
import type { DashboardLocale } from '@meshkee/dashboard-core'
const en = {
'app.dashboardName': 'Customer Dashboard',
'app.role.customer': 'Customer',
'app.storeFallback': 'Store',
'app.poweredBy': 'powered by Meshkee.app',
'nav.home': 'Home',
'nav.cart': 'Shopping Cart',
'nav.profile': 'My Profile',
'nav.addresses': 'My Addresses',
'nav.orders': 'My Orders',
'nav.favorites': 'My Favorites',
'nav.help': 'Help Center',
'nav.logout': 'Logout',
'header.toggleMenu': 'Toggle menu',
'header.messages': 'Messages',
'header.notifications': 'Notifications',
'header.changePassword': 'Change password',
'header.myProfile': 'My Profile',
'home.welcome': 'Welcome, dear {name}.',
'home.welcomeFallback': 'there',
'home.subtitle': 'Manage your profile, addresses, orders, and favorites in one place.',
'home.card.profile.title': 'My Profile',
'home.card.profile.desc': 'View and update your personal information and contact details.',
'home.card.profile.link': 'View profile',
'home.card.addresses.title': 'My Addresses',
'home.card.addresses.desc': 'Manage your shipping addresses for checkout and deliveries.',
'home.card.addresses.link': 'View addresses',
'home.card.orders.title': 'My Orders',
'home.card.orders.desc': 'Track your orders, view order history and order details.',
'home.card.orders.link': 'View orders',
'home.card.favorites.title': 'My Favorites',
'home.card.favorites.desc': 'Browse and manage your saved favorite products.',
'home.card.favorites.link': 'View favorites',
'profile.title': 'My Profile',
'profile.subtitle': 'Update your personal information and contact details.',
'profile.section.account': 'Account',
'profile.section.about': 'About',
'profile.section.social': 'Social',
'profile.mobile': 'Mobile number',
'profile.email': 'Email',
'profile.firstName': 'First name (FA)',
'profile.lastName': 'Last name (FA)',
'profile.firstNameEn': 'First name (EN)',
'profile.lastNameEn': 'Last name (EN)',
'profile.landline': 'Landline',
'profile.backupPhone': 'Backup phone number',
'profile.about': 'About',
'profile.instagram': 'Instagram',
'profile.telegram': 'Telegram',
'profile.linkedin': 'LinkedIn',
'profile.emailPlaceholder': 'you@example.com',
'profile.save': 'Save changes',
'profile.saving': 'Saving...',
'profile.toast.success': 'Profile updated successfully.',
'profile.error.update': 'Unable to update profile. Please try again.',
'addresses.title': 'My Addresses',
'addresses.subtitle': 'Manage your shipping addresses for orders at this store.',
'addresses.save': 'Save addresses',
'addresses.saveOne': 'Save address',
'addresses.saving': 'Saving...',
'addresses.cancel': 'Cancel',
'addresses.editorTitle': 'Saved addresses',
'addresses.add': 'Add address',
'addresses.edit': 'Edit address',
'addresses.empty': 'No addresses yet.',
'addresses.loading': 'Loading addresses...',
'addresses.label': 'Address name',
'addresses.labelPlaceholder': 'e.g. Home, Office',
'addresses.optional': '(optional)',
'addresses.province': 'Province',
'addresses.city': 'City',
'addresses.address': 'Address',
'addresses.postalCode': 'Postal code',
'addresses.landline': 'Landline',
'addresses.selectProvince': 'Select province',
'addresses.selectCity': 'Select city',
'addresses.streetPlaceholder': 'Street, plaque, unit',
'addresses.postalPlaceholder': 'Postal code',
'addresses.landlinePlaceholder': 'Landline',
'addresses.remove': 'Remove address',
'addresses.modal.addTitle': 'Add address',
'addresses.modal.editTitle': 'Edit address',
'addresses.modal.subtitle': 'Enter the shipping address details.',
'addresses.toast.removed': 'Address removed.',
'addresses.toast.saved': 'Addresses saved.',
'addresses.toast.created': 'Address saved.',
'addresses.toast.updated': 'Address updated.',
'addresses.error.load': 'Unable to load addresses.',
'addresses.error.remove': 'Unable to remove address.',
'addresses.error.incomplete': 'Add at least one complete address.',
'addresses.error.incompleteForm': 'Please fill in all required fields.',
'addresses.error.save': 'Unable to save address. Please try again.',
'addresses.error.loadLocations': 'Unable to load provinces.',
'addresses.error.loadCities': 'Unable to load cities.',
'orders.title': 'My Orders',
'orders.subtitle': 'View your order history and details.',
'orders.listTitle': 'Order list',
'orders.showing': 'Showing {from} - {to} of {total}',
'orders.none': 'No orders',
'orders.empty': 'You have no orders yet.',
'orders.loading': 'Loading orders...',
'orders.error.load': 'Unable to load orders.',
'orders.col.orderId': 'Order ID',
'orders.col.items': 'Items',
'orders.col.total': 'Total cost',
'orders.col.date': 'Date & time',
'orders.col.step': 'Step',
'orders.col.source': 'Registered by',
'orders.col.actions': 'Actions',
'orders.pageMeta': 'Page {page} / {totalPages} · {pageSize} per page · {total} total',
'orders.viewItems': 'View items',
'orders.source.operator': 'Operator',
'orders.source.app': 'Application',
'orders.source.website': 'Website',
'orders.step.processing': 'Under processing',
'orders.step.ready': 'Ready for shipping',
'orders.step.shipped': 'Shipped',
'orders.step.delivered': 'Delivered',
'orderItems.title': 'Order items',
'orderItems.customer': 'Customer',
'orderItems.phone': 'Phone',
'orderItems.items': 'Items',
'orderItems.empty': 'No items in this order.',
'orderItems.product': 'Product',
'orderItems.qty': 'Qty',
'orderItems.unitPrice': 'Unit price',
'orderItems.lineTotal': 'Line total',
'orderItems.sku': 'SKU: {sku}',
'orderItems.summary': 'Order total · {count} {itemsLabel}',
'orderItems.item': 'item',
'orderItems.itemsPlural': 'items',
'orderItems.close': 'Close',
'favorites.title': 'My Favorites',
'favorites.subtitle': 'Products you have saved for later.',
'favorites.loading': 'Loading favorites...',
'favorites.empty': 'No favorites yet.',
'favorites.error.load': 'Unable to load favorites.',
'favorites.toast.removed': 'Removed from favorites.',
'favorites.error.remove': 'Unable to remove favorite.',
'favorites.cartSoon': 'Shopping cart is coming soon.',
'favorites.remove': 'Remove from favorites',
'favorites.addToCart': 'Add to shopping cart',
'favorites.festival': 'Festival',
'favorites.inStock': '{count} in stock',
'favorites.variantOne': '1 variant',
'favorites.variantMany': '{count} variants',
'title.signIn': 'Sign in',
'title.checkout': 'Checkout',
'title.cart': 'Shopping Cart',
'title.delivery': 'Delivery',
'title.payment': 'Payment',
'title.success': 'Success',
'title.failed': 'Failed',
'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.',
'signup.title': 'Create account',
'signup.subtitle': 'Register as a customer of {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}',
} as const
type MessageKey = keyof typeof en
const fa: Record<MessageKey, string> = {
'app.dashboardName': 'پنل مشتری',
'app.role.customer': 'مشتری',
'app.storeFallback': 'فروشگاه',
'app.poweredBy': 'قدرت‌گرفته از Meshkee.app',
'nav.home': 'خانه',
'nav.cart': 'سبد خرید',
'nav.profile': 'پروفایل من',
'nav.addresses': 'آدرس‌های من',
'nav.orders': 'سفارش‌های من',
'nav.favorites': 'علاقه‌مندی‌ها',
'nav.help': 'مرکز راهنما',
'nav.logout': 'خروج',
'header.toggleMenu': 'باز و بسته کردن منو',
'header.messages': 'پیام‌ها',
'header.notifications': 'اعلان‌ها',
'header.changePassword': 'تغییر رمز عبور',
'header.myProfile': 'پروفایل من',
'home.welcome': '{name} عزیز، خوش آمدی.',
'home.welcomeFallback': 'کاربر',
'home.subtitle': 'پروفایل، آدرس‌ها، سفارش‌ها و علاقه‌مندی‌ها را از یکجا مدیریت کنید.',
'home.card.profile.title': 'پروفایل من',
'home.card.profile.desc': 'اطلاعات شخصی و راه‌های ارتباطی خود را مشاهده و به‌روزرسانی کنید.',
'home.card.profile.link': 'مشاهده پروفایل',
'home.card.addresses.title': 'آدرس‌های من',
'home.card.addresses.desc': 'آدرس‌های ارسال برای تسویه‌حساب و تحویل را مدیریت کنید.',
'home.card.addresses.link': 'مشاهده آدرس‌ها',
'home.card.orders.title': 'سفارش‌های من',
'home.card.orders.desc': 'سفارش‌ها را پیگیری کنید و تاریخچه و جزئیات را ببینید.',
'home.card.orders.link': 'مشاهده سفارش‌ها',
'home.card.favorites.title': 'علاقه‌مندی‌ها',
'home.card.favorites.desc': 'محصولات ذخیره‌شده مورد علاقه‌تان را ببینید و مدیریت کنید.',
'home.card.favorites.link': 'مشاهده علاقه‌مندی‌ها',
'profile.title': 'پروفایل من',
'profile.subtitle': 'اطلاعات شخصی و راه‌های ارتباطی خود را به‌روزرسانی کنید.',
'profile.section.account': 'حساب کاربری',
'profile.section.about': 'درباره من',
'profile.section.social': 'شبکه‌های اجتماعی',
'profile.mobile': 'شماره موبایل',
'profile.email': 'ایمیل',
'profile.firstName': 'نام (فارسی)',
'profile.lastName': 'نام خانوادگی (فارسی)',
'profile.firstNameEn': 'نام (انگلیسی)',
'profile.lastNameEn': 'نام خانوادگی (انگلیسی)',
'profile.landline': 'تلفن ثابت',
'profile.backupPhone': 'شماره تماس پشتیبان',
'profile.about': 'درباره من',
'profile.instagram': 'اینستاگرام',
'profile.telegram': 'تلگرام',
'profile.linkedin': 'لینکدین',
'profile.emailPlaceholder': 'you@example.com',
'profile.save': 'ذخیره تغییرات',
'profile.saving': 'در حال ذخیره...',
'profile.toast.success': 'پروفایل با موفقیت به‌روزرسانی شد.',
'profile.error.update': 'به‌روزرسانی پروفایل ممکن نشد. دوباره تلاش کنید.',
'addresses.title': 'آدرس‌های من',
'addresses.subtitle': 'آدرس‌های ارسال سفارش در این فروشگاه را مدیریت کنید.',
'addresses.save': 'ذخیره آدرس‌ها',
'addresses.saveOne': 'ذخیره آدرس',
'addresses.saving': 'در حال ذخیره...',
'addresses.cancel': 'انصراف',
'addresses.editorTitle': 'آدرس‌های ذخیره‌شده',
'addresses.add': 'افزودن آدرس',
'addresses.edit': 'ویرایش آدرس',
'addresses.empty': 'هنوز آدرسی ثبت نشده است.',
'addresses.loading': 'در حال بارگذاری آدرس‌ها...',
'addresses.label': 'عنوان آدرس',
'addresses.labelPlaceholder': 'مثلاً خانه، محل کار',
'addresses.optional': '(اختیاری)',
'addresses.province': 'استان',
'addresses.city': 'شهر',
'addresses.address': 'آدرس',
'addresses.postalCode': 'کد پستی',
'addresses.landline': 'تلفن ثابت',
'addresses.selectProvince': 'انتخاب استان',
'addresses.selectCity': 'انتخاب شهر',
'addresses.streetPlaceholder': 'خیابان، پلاک، واحد',
'addresses.postalPlaceholder': 'کد پستی',
'addresses.landlinePlaceholder': 'تلفن ثابت',
'addresses.remove': 'حذف آدرس',
'addresses.modal.addTitle': 'افزودن آدرس',
'addresses.modal.editTitle': 'ویرایش آدرس',
'addresses.modal.subtitle': 'جزئیات آدرس ارسال را وارد کنید.',
'addresses.toast.removed': 'آدرس حذف شد.',
'addresses.toast.saved': 'آدرس‌ها ذخیره شدند.',
'addresses.toast.created': 'آدرس ذخیره شد.',
'addresses.toast.updated': 'آدرس به‌روزرسانی شد.',
'addresses.error.load': 'بارگذاری آدرس‌ها ممکن نشد.',
'addresses.error.remove': 'حذف آدرس ممکن نشد.',
'addresses.error.incomplete': 'حداقل یک آدرس کامل اضافه کنید.',
'addresses.error.incompleteForm': 'لطفاً همه فیلدهای الزامی را تکمیل کنید.',
'addresses.error.save': 'ذخیره آدرس ممکن نشد. دوباره تلاش کنید.',
'addresses.error.loadLocations': 'بارگذاری استان‌ها ممکن نشد.',
'addresses.error.loadCities': 'بارگذاری شهرها ممکن نشد.',
'orders.title': 'سفارش‌های من',
'orders.subtitle': 'تاریخچه و جزئیات سفارش‌های خود را ببینید.',
'orders.listTitle': 'فهرست سفارش‌ها',
'orders.showing': 'نمایش {from} تا {to} از {total}',
'orders.none': 'بدون سفارش',
'orders.empty': 'هنوز سفارشی ندارید.',
'orders.loading': 'در حال بارگذاری سفارش‌ها...',
'orders.error.load': 'بارگذاری سفارش‌ها ممکن نشد.',
'orders.col.orderId': 'شماره سفارش',
'orders.col.items': 'اقلام',
'orders.col.total': 'مبلغ کل',
'orders.col.date': 'تاریخ و ساعت',
'orders.col.step': 'وضعیت',
'orders.col.source': 'ثبت‌شده توسط',
'orders.col.actions': 'عملیات',
'orders.pageMeta': 'صفحه {page} / {totalPages} · {pageSize} در هر صفحه · {total} کل',
'orders.viewItems': 'مشاهده اقلام',
'orders.source.operator': 'اپراتور',
'orders.source.app': 'اپلیکیشن',
'orders.source.website': 'وب‌سایت',
'orders.step.processing': 'در حال پردازش',
'orders.step.ready': 'آماده ارسال',
'orders.step.shipped': 'ارسال‌شده',
'orders.step.delivered': 'تحویل‌شده',
'orderItems.title': 'اقلام سفارش',
'orderItems.customer': 'مشتری',
'orderItems.phone': 'تلفن',
'orderItems.items': 'اقلام',
'orderItems.empty': 'اقلامی در این سفارش نیست.',
'orderItems.product': 'محصول',
'orderItems.qty': 'تعداد',
'orderItems.unitPrice': 'قیمت واحد',
'orderItems.lineTotal': 'جمع ردیف',
'orderItems.sku': 'کد کالا: {sku}',
'orderItems.summary': 'جمع سفارش · {count} {itemsLabel}',
'orderItems.item': 'قلم',
'orderItems.itemsPlural': 'قلم',
'orderItems.close': 'بستن',
'favorites.title': 'علاقه‌مندی‌ها',
'favorites.subtitle': 'محصولاتی که برای بعد ذخیره کرده‌اید.',
'favorites.loading': 'در حال بارگذاری علاقه‌مندی‌ها...',
'favorites.empty': 'هنوز علاقه‌مندی ندارید.',
'favorites.error.load': 'بارگذاری علاقه‌مندی‌ها ممکن نشد.',
'favorites.toast.removed': 'از علاقه‌مندی‌ها حذف شد.',
'favorites.error.remove': 'حذف از علاقه‌مندی‌ها ممکن نشد.',
'favorites.cartSoon': 'سبد خرید به‌زودی فعال می‌شود.',
'favorites.remove': 'حذف از علاقه‌مندی‌ها',
'favorites.addToCart': 'افزودن به سبد خرید',
'favorites.festival': 'جشنواره',
'favorites.inStock': '{count} موجود',
'favorites.variantOne': '۱ تنوع',
'favorites.variantMany': '{count} تنوع',
'title.signIn': 'ورود',
'title.checkout': 'تسویه‌حساب',
'title.cart': 'سبد خرید',
'title.delivery': 'ارسال',
'title.payment': 'پرداخت',
'title.success': 'موفق',
'title.failed': 'ناموفق',
'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': 'ارسال کد تأیید ممکن نشد.',
'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} ارسال شد',
}
const dictionaries: Record<DashboardLocale, Record<MessageKey, string>> = {
en: en as Record<MessageKey, string>,
fa,
}
export type CustomerMessageKey = MessageKey
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 getCustomerRouteTitleRules(locale: DashboardLocale) {
const t = (key: MessageKey) => translate(locale, key)
return [
{ match: '/login', labels: [t('title.signIn')] },
{ match: '/checkout/login', labels: [t('title.checkout'), t('title.signIn')] },
{ match: '/checkout/cart', labels: [t('title.checkout'), t('title.cart')] },
{ match: '/checkout/delivery', labels: [t('title.checkout'), t('title.delivery')] },
{ match: '/checkout/payment', labels: [t('title.checkout'), t('title.payment')] },
{ match: '/checkout/success', labels: [t('title.checkout'), t('title.success')] },
{ match: '/checkout/failed', labels: [t('title.checkout'), t('title.failed')] },
{ match: '/checkout', labels: [t('title.checkout')] },
{ match: '/profile', labels: [t('nav.profile')] },
{ match: '/addresses', labels: [t('nav.addresses')] },
{ match: '/orders', labels: [t('nav.orders')] },
{ match: '/favorites', labels: [t('nav.favorites')] },
{ match: '/', labels: [t('nav.home')] },
]
}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { translate, type CustomerMessageKey } from './messages'
export function useT() {
const { locale } = useLocale()
return useCallback(
(key: CustomerMessageKey, vars?: Record<string, string | number>) =>
translate(locale, key, vars),
[locale],
)
}
+3 -1
View File
@@ -1,6 +1,8 @@
@import '@meshkee/dashboard-core/styles/tokens.css'; @import '@meshkee/dashboard-core/styles/tokens.css';
/* Customer app Farsi typography — Yekan Bakh for body, inputs, and placeholders */ /* Customer: Montserrat (EN) + Yekan Bakh (FA) via --font-ui stack */
:root { :root {
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'YekanBakh', Tahoma, sans-serif; --font-fa: 'YekanBakh', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
} }
+119 -17
View File
@@ -1,9 +1,3 @@
.form {
display: flex;
flex-direction: column;
gap: 24px;
}
.section { .section {
width: 100%; width: 100%;
background: var(--glass-bg); background: var(--glass-bg);
@@ -15,26 +9,127 @@
padding: 24px; padding: 24px;
} }
.actions { .sectionTitle {
display: flex; margin: 0 0 16px;
justify-content: flex-end; font-size: 16px;
font-weight: 600;
color: var(--text-primary);
} }
.saveBtn { .status {
padding: 10px 20px; margin: 0;
font-size: 14px;
color: var(--text-muted);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 16px;
color: var(--text-muted);
text-align: center;
}
.empty p {
margin: 0;
font-size: 14px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.55);
}
.rowLine {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
white-space: nowrap;
}
.rowLabel {
flex-shrink: 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #fff; color: var(--text-primary);
background: var(--primary);
border-radius: var(--radius-sm);
} }
.saveBtn:disabled { .rowText {
opacity: 0.6; min-width: 0;
cursor: not-allowed; overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--text-secondary);
}
.rowActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.editBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 8px;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.editBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.addFab {
position: fixed;
inset-inline-end: 32px;
bottom: 32px;
z-index: 50;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
} }
.error { .error {
margin-bottom: 16px;
padding: 12px 16px; padding: 12px 16px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
@@ -42,3 +137,10 @@
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2); border: 1px solid rgba(239, 68, 68, 0.2);
} }
@media (max-width: 768px) {
.addFab {
inset-inline-end: 20px;
bottom: 20px;
}
}
+154 -212
View File
@@ -1,259 +1,201 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { import { MapPin, Pencil, Plus, Trash2 } from 'lucide-react'
AddressListEditor, import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
Breadcrumbs, import { AddressFormModal } from '../components/AddressFormModal'
createEmptyAddressItem, import { Tooltip } from '../components/Tooltip'
matchCityByName, import { useT } from '../i18n/useT'
matchProvinceByName,
useToast,
type AddressListItem,
type CityOption,
} from '@meshkee/dashboard-ui'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
createAddress,
listAddresses, listAddresses,
removeAddress, removeAddress,
updateAddress,
type UserAddress, type UserAddress,
type UserAddressInput,
} from '../services/addressService' } from '../services/addressService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
} from '../services/citiesService'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import rowBtnStyles from '../components/VariationsModal.module.css'
import styles from './AddressesPage.module.css' import styles from './AddressesPage.module.css'
type AddressDraft = AddressListItem
function toDraft(
item: UserAddress,
provinces: CityOption[],
citiesByProvince: Record<string, CityOption[]>,
): AddressDraft {
const province = matchProvinceByName(item.province, provinces)
const cities = province ? (citiesByProvince[province.slug] ?? []) : []
const city = matchCityByName(item.city, cities)
return {
id: item.id,
provinceSlug: province?.slug ?? '',
province: province?.nameEn ?? item.province,
city: city?.nameEn ?? item.city,
address: item.address,
postalCode: item.postalCode ?? '',
landline: item.landline ?? '',
}
}
function isCompleteAddress(item: AddressDraft) {
return item.province.trim() && item.city.trim() && item.address.trim()
}
export function AddressesPage() { export function AddressesPage() {
const { showToast } = useToast() const { showToast } = useToast()
const [provinces, setProvinces] = useState<CityOption[]>([]) const t = useT()
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({}) const [addresses, setAddresses] = useState<UserAddress[]>([])
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [removingId, setRemovingId] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null)
const loadAddresses = useCallback(
async (signal?: AbortSignal) => {
setLoading(true)
setError('')
try {
const data = await listAddresses(signal)
if (signal?.aborted) return
setAddresses(data.items)
} catch (err) {
if (isAbortError(err) || signal?.aborted) return
setError(err instanceof ApiError ? err.message : t('addresses.error.load'))
} finally {
if (!signal?.aborted) setLoading(false)
}
},
[t],
)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
void loadAddresses(controller.signal)
async function load() {
setLoading(true)
setError('')
try {
const [provinceItems, data] = await Promise.all([
listIranProvinces(controller.signal),
listAddresses(controller.signal),
])
if (controller.signal.aborted) return
setProvinces(provinceItems)
const draftItems =
data.items.length > 0 ? data.items : []
const slugs = [
...new Set(
draftItems
.map((item) => matchProvinceByName(item.province, provinceItems)?.slug)
.filter(Boolean) as string[],
),
]
const cityGroups = await Promise.all(
slugs.map(async (slug) => ({
slug,
cities: await listCitiesByProvinceSlug(slug, controller.signal),
})),
)
if (controller.signal.aborted) return
const citiesMap = Object.fromEntries(cityGroups.map((group) => [group.slug, group.cities]))
setCitiesByProvince(citiesMap)
setAddresses(
draftItems.length > 0
? draftItems.map((item) => toDraft(item, provinceItems, citiesMap))
: [createEmptyAddressItem()],
)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load addresses.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort() return () => controller.abort()
}, []) }, [loadAddresses])
function updateAddressDraft(index: number, patch: Partial<AddressDraft>) { function openCreateModal() {
setAddresses((prev) => setEditingAddress(null)
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)), setModalOpen(true)
)
} }
async function handleProvinceChange(index: number, provinceSlug: string) { function openEditModal(item: UserAddress) {
const province = provinces.find((item) => item.slug === provinceSlug) setEditingAddress(item)
updateAddressDraft(index, { setModalOpen(true)
provinceSlug,
province: province?.nameEn ?? '',
city: '',
})
if (provinceSlug && !citiesByProvince[provinceSlug]) {
const cities = await listCitiesByProvinceSlug(provinceSlug)
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
}
} }
function addAddressRow() { function handleSaved(saved: UserAddress) {
setAddresses((prev) => [...prev, createEmptyAddressItem()]) setAddresses((prev) => {
} const index = prev.findIndex((item) => item.id === saved.id)
if (index >= 0) {
async function handleRemove(index: number) { const next = [...prev]
const target = addresses[index] next[index] = saved
if (!target) return return next
if (!target.id) {
setAddresses((prev) =>
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
)
return
}
setSaving(true)
setError('')
try {
await removeAddress(target.id)
setAddresses((prev) => {
const next = prev.filter((_, i) => i !== index)
return next.length > 0 ? next : [createEmptyAddressItem()]
})
showToast('Address removed.', 'success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove address.')
} finally {
setSaving(false)
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const payload = addresses.filter(isCompleteAddress)
if (payload.length === 0) {
setError('Add at least one complete address.')
return
}
setSaving(true)
try {
const saved: AddressDraft[] = []
for (const item of payload) {
const input: UserAddressInput = {
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
postalCode: item.postalCode.trim() || undefined,
landline: item.landline?.trim() || undefined,
}
if (item.id) {
const result = await updateAddress(item.id, input)
saved.push(toDraft(result.address, provinces, citiesByProvince))
} else {
const result = await createAddress(input)
saved.push(toDraft(result.address, provinces, citiesByProvince))
}
} }
return [saved, ...prev]
})
}
setAddresses(saved.length > 0 ? saved : [createEmptyAddressItem()]) async function handleRemove(item: UserAddress) {
showToast('Addresses saved.', 'success') setRemovingId(item.id)
setError('')
try {
await removeAddress(item.id)
setAddresses((prev) => prev.filter((row) => row.id !== item.id))
showToast(t('addresses.toast.removed'), 'success')
} catch (err) { } catch (err) {
const message = const message = err instanceof ApiError ? err.message : t('addresses.error.remove')
err instanceof ApiError ? err.message : 'Unable to save addresses. Please try again.'
setError(message) setError(message)
showToast(message, 'error') showToast(message, 'error')
} finally { } finally {
setSaving(false) setRemovingId(null)
} }
} }
const hasAddresses = useMemo( function formatRowLine(item: UserAddress) {
() => addresses.some((item) => isCompleteAddress(item)), return [
[addresses], item.label || null,
) item.province,
item.city,
item.address,
item.postalCode,
item.landline,
]
.filter(Boolean)
.join(' · ')
}
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Addresses' }]} /> <Breadcrumbs
items={[{ label: t('nav.home'), href: '/' }, { label: t('addresses.title') }]}
/>
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Addresses</h2> <h2 className={pageStyles.pageTitle}>{t('addresses.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('addresses.subtitle')}</p>
Manage your shipping addresses for orders at this store.
</p>
</div> </div>
</div> </div>
<form className={styles.form} onSubmit={handleSubmit}> {error && (
{error && ( <div className={styles.error} role="alert">
<div className={styles.error} role="alert"> {error}
{error} </div>
)}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('addresses.editorTitle')}</h3>
{loading && <p className={styles.status}>{t('addresses.loading')}</p>}
{!loading && addresses.length === 0 && (
<div className={styles.empty}>
<MapPin size={28} />
<p>{t('addresses.empty')}</p>
</div> </div>
)} )}
<section className={styles.section}> {!loading && addresses.length > 0 && (
<AddressListEditor <ul className={styles.list}>
addresses={addresses} {addresses.map((item) => (
provinces={provinces} <li key={item.id} className={styles.row}>
citiesByProvince={citiesByProvince} <div className={styles.rowLine} title={formatRowLine(item)}>
onAddressChange={updateAddressDraft} {item.label && <span className={styles.rowLabel}>{item.label}</span>}
onProvinceChange={handleProvinceChange} <span className={styles.rowText}>
onAdd={addAddressRow} {[
onRemove={(index) => void handleRemove(index)} item.province,
disabled={saving} item.city,
loading={loading} item.address,
/> item.postalCode,
</section> item.landline,
]
.filter(Boolean)
.join(' · ')}
</span>
</div>
<div className={styles.actions}> <div className={styles.rowActions}>
<button type="submit" className={styles.saveBtn} disabled={saving || loading || !hasAddresses}> <Tooltip label={t('addresses.edit')}>
{saving ? 'Saving...' : 'Save addresses'} <button
</button> type="button"
</div> className={styles.editBtn}
</form> onClick={() => openEditModal(item)}
aria-label={t('addresses.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('addresses.remove')}>
<button
type="button"
className={rowBtnStyles.removeRowBtn}
onClick={() => void handleRemove(item)}
disabled={removingId === item.id}
aria-label={t('addresses.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</section>
<button
type="button"
className={styles.addFab}
onClick={openCreateModal}
aria-label={t('addresses.add')}
>
<Plus size={26} strokeWidth={2.5} />
</button>
<AddressFormModal
open={modalOpen}
address={editingAddress}
onClose={() => {
setModalOpen(false)
setEditingAddress(null)
}}
onSaved={handleSaved}
/>
</main> </main>
) )
} }
+14 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react' import { Heart } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard' import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
listFavorites, listFavorites,
@@ -16,6 +17,7 @@ const PAGE_SIZE = 24
export function FavoritesPage() { export function FavoritesPage() {
const { showToast } = useToast() const { showToast } = useToast()
const t = useT()
const [data, setData] = useState<FavoritesListResponse | null>(null) const [data, setData] = useState<FavoritesListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -35,7 +37,7 @@ export function FavoritesPage() {
setData(response) setData(response)
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load favorites.') setError(err instanceof ApiError ? err.message : t('favorites.error.load'))
} finally { } finally {
if (!controller.signal.aborted) setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
@@ -43,7 +45,7 @@ export function FavoritesPage() {
void load() void load()
return () => controller.abort() return () => controller.abort()
}, [page]) }, [page, t])
async function handleRemove(listing: FavoriteListing) { async function handleRemove(listing: FavoriteListing) {
setRemovingId(listing.favoriteId) setRemovingId(listing.favoriteId)
@@ -58,10 +60,10 @@ export function FavoritesPage() {
} }
: prev, : prev,
) )
showToast('Removed from favorites.', 'success') showToast(t('favorites.toast.removed'), 'success')
} catch (err) { } catch (err) {
const message = const message =
err instanceof ApiError ? err.message : 'Unable to remove favorite.' err instanceof ApiError ? err.message : t('favorites.error.remove')
showToast(message, 'error') showToast(message, 'error')
} finally { } finally {
setRemovingId(null) setRemovingId(null)
@@ -69,17 +71,19 @@ export function FavoritesPage() {
} }
function handleAddToCart() { function handleAddToCart() {
showToast('Shopping cart is coming soon.', 'success') showToast(t('favorites.cartSoon'), 'success')
} }
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Favorites' }]} /> <Breadcrumbs
items={[{ label: t('nav.home'), href: '/' }, { label: t('favorites.title') }]}
/>
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Favorites</h2> <h2 className={pageStyles.pageTitle}>{t('favorites.title')}</h2>
<p className={pageStyles.pageSubtitle}>Products you have saved for later.</p> <p className={pageStyles.pageSubtitle}>{t('favorites.subtitle')}</p>
</div> </div>
</div> </div>
@@ -89,12 +93,12 @@ export function FavoritesPage() {
</div> </div>
)} )}
{loading && <p className={styles.status}>Loading favorites...</p>} {loading && <p className={styles.status}>{t('favorites.loading')}</p>}
{!loading && data?.items.length === 0 && ( {!loading && data?.items.length === 0 && (
<div className={styles.empty}> <div className={styles.empty}>
<Heart size={32} /> <Heart size={32} />
<p>No favorites yet.</p> <p>{t('favorites.empty')}</p>
</div> </div>
)} )}
+47 -44
View File
@@ -1,73 +1,76 @@
import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react' import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react'
import { SectionCard, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { SectionCard } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
const sections = [ export function HomePage() {
{ const { user } = useAuth()
icon: User, const { locale } = useLocale()
title: 'My Profile', const t = useT()
description: 'View and update your personal information and contact details.', const firstName =
linkText: 'View profile', (locale === 'en' ? user?.firstNameEn : user?.firstName) ||
href: '/profile', user?.firstName ||
}, user?.firstNameEn ||
{ t('home.welcomeFallback')
icon: MapPin,
title: 'My Addresses',
description: 'Manage your shipping addresses for checkout and deliveries.',
linkText: 'View addresses',
href: '/addresses',
},
{
icon: ShoppingBag,
title: 'My Orders',
description: 'Track your orders, view order history and order details.',
linkText: 'View orders',
href: '/orders',
},
{
icon: Heart,
title: 'My Favorites',
description: 'Browse and manage your saved favorite products.',
linkText: 'View favorites',
href: '/favorites',
},
]
function getFormattedDate() { const sections = [
return new Intl.DateTimeFormat('en-US', { {
icon: User,
title: t('home.card.profile.title'),
description: t('home.card.profile.desc'),
linkText: t('home.card.profile.link'),
href: '/profile',
},
{
icon: MapPin,
title: t('home.card.addresses.title'),
description: t('home.card.addresses.desc'),
linkText: t('home.card.addresses.link'),
href: '/addresses',
},
{
icon: ShoppingBag,
title: t('home.card.orders.title'),
description: t('home.card.orders.desc'),
linkText: t('home.card.orders.link'),
href: '/orders',
},
{
icon: Heart,
title: t('home.card.favorites.title'),
description: t('home.card.favorites.desc'),
linkText: t('home.card.favorites.link'),
href: '/favorites',
},
]
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
weekday: 'long', weekday: 'long',
}).format(new Date()) }).format(new Date())
}
export function HomePage() {
const { user } = useAuth()
const firstName = user?.firstName || 'there'
return ( return (
<main className={styles.content}> <main className={styles.content}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}> <h2 className={styles.pageTitle}>
Welcome back, {firstName}! <span aria-hidden="true">👋</span> {t('home.welcome', { name: firstName })}
</h2> </h2>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('home.subtitle')}</p>
Manage your profile, addresses, orders, and favorites in one place.
</p>
</div> </div>
<div className={styles.dateBadge}> <div className={styles.dateBadge}>
<CalendarDays size={16} /> <CalendarDays size={16} />
<span>{getFormattedDate()}</span> <span>{formattedDate}</span>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{sections.map((section) => ( {sections.map((section) => (
<SectionCard key={section.title} {...section} /> <SectionCard key={section.href} {...section} />
))} ))}
</div> </div>
</main> </main>
+9 -4
View File
@@ -24,11 +24,15 @@
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 12px; gap: 12px;
margin-bottom: 28px; margin-bottom: 28px;
} }
.langSelect {
margin-inline-start: auto;
}
.logo { .logo {
display: block; display: block;
width: 48px; width: 48px;
@@ -112,7 +116,7 @@
.inputIcon { .inputIcon {
position: absolute; position: absolute;
left: 12px; inset-inline-start: 12px;
color: var(--text-muted); color: var(--text-muted);
pointer-events: none; pointer-events: none;
} }
@@ -120,7 +124,8 @@
.inputWrap input { .inputWrap input {
width: 100%; width: 100%;
min-height: var(--field-height); 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); font-size: var(--field-font-size);
line-height: 1.4; line-height: 1.4;
font-family: inherit; font-family: inherit;
@@ -140,7 +145,7 @@
.togglePassword { .togglePassword {
position: absolute; position: absolute;
right: 12px; inset-inline-end: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+68 -68
View File
@@ -16,6 +16,8 @@ import {
sendOtp, sendOtp,
verifyOtp, verifyOtp,
} from '../services/authService' } from '../services/authService'
import { LanguageSelect } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import meshkeeLogo from '../assets/meshkee-logo.png' import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css' import styles from './LoginPage.module.css'
@@ -36,6 +38,7 @@ export function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
const { businessName, logoUrl } = useTenantBranding() const { businessName, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain() const tenantDomain = getTenantDomain()
const t = useT()
const [view, setView] = useState<AuthView>('login') const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone') const [smsStep, setSmsStep] = useState<SmsStep>('phone')
@@ -114,7 +117,7 @@ export function LoginPage() {
setSmsStep('code') setSmsStep('code')
startCountdown() startCountdown()
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to send verification code.') handleApiError(err, t('login.error.sendCode'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -130,7 +133,7 @@ export function LoginPage() {
await login(cellNumber, password) await login(cellNumber, password)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to sign in. Check your connection and try again.') handleApiError(err, t('login.error.signIn'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -141,12 +144,12 @@ export function LoginPage() {
clearMessages() clearMessages()
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match.') setError(t('signup.error.match'))
return return
} }
if (password.length < 8) { if (password.length < 8) {
setError('Password must be at least 8 characters.') setError(t('signup.error.length'))
return return
} }
@@ -180,7 +183,7 @@ export function LoginPage() {
await login(cellNumber, password) await login(cellNumber, password)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to create account.') handleApiError(err, t('signup.error.create'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -191,7 +194,7 @@ export function LoginPage() {
clearMessages() clearMessages()
if (newPassword.length < 8) { if (newPassword.length < 8) {
setError('Password must be at least 8 characters.') setError(t('forgot.error.length'))
return return
} }
@@ -200,12 +203,10 @@ export function LoginPage() {
try { try {
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode) await verifyOtp(cellNumber, smsCode)
setInfo( setInfo(t('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.',
)
setTimeout(() => switchView('login'), 2500) setTimeout(() => switchView('login'), 2500)
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to verify code.') handleApiError(err, t('forgot.error.verify'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -221,14 +222,14 @@ export function LoginPage() {
await verifyOtp(cellNumber, smsCode) await verifyOtp(cellNumber, smsCode)
if (!password) { if (!password) {
setError('Enter your account password to complete sign-in after SMS verification.') setError(t('otp.error.password'))
return return
} }
await login(cellNumber, password) await login(cellNumber, password)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
handleApiError(err, 'Unable to sign in with SMS verification.') handleApiError(err, t('otp.error.signIn'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -241,14 +242,17 @@ export function LoginPage() {
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} /> <img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.domain}>{businessName || tenantDomain}</span> <span className={styles.domain}>{businessName || tenantDomain}</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>
</div> </div>
{view === 'login' && ( {view === 'login' && (
<> <>
<h1 className={styles.title}>Welcome back</h1> <h1 className={styles.title}>{t('login.welcome')}</h1>
<p className={styles.subtitle}>Sign in with your mobile number</p> <p className={styles.subtitle}>{t('login.subtitle')}</p>
<form className={styles.form} onSubmit={handleLogin}> <form className={styles.form} onSubmit={handleLogin}>
{error && ( {error && (
@@ -259,7 +263,7 @@ export function LoginPage() {
{info && <div className={styles.info}>{info}</div>} {info && <div className={styles.info}>{info}</div>}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="login-phone">Mobile number</label> <label htmlFor="login-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -276,13 +280,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="login-password">Password</label> <label htmlFor="login-password">{t('login.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="login-password" id="login-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Enter your password" placeholder={t('login.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -293,7 +297,7 @@ export function LoginPage() {
type="button" type="button"
className={styles.togglePassword} className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting} disabled={isSubmitting}
> >
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -308,17 +312,17 @@ export function LoginPage() {
onClick={() => switchView('forgot')} onClick={() => switchView('forgot')}
disabled={isSubmitting} disabled={isSubmitting}
> >
Forgot password? {t('login.forgot')}
</button> </button>
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'} {isSubmitting ? t('login.signingIn') : t('login.signIn')}
</button> </button>
</form> </form>
<div className={styles.divider}> <div className={styles.divider}>
<span>or</span> <span>{t('login.or')}</span>
</div> </div>
<button <button
@@ -328,18 +332,18 @@ export function LoginPage() {
disabled={isSubmitting} disabled={isSubmitting}
> >
<KeyRound size={18} /> <KeyRound size={18} />
One-time login with SMS {t('login.otp')}
</button> </button>
<p className={styles.footerText}> <p className={styles.footerText}>
Don&apos;t have an account?{' '} {t('login.noAccount')}{' '}
<button <button
type="button" type="button"
className={styles.linkBtn} className={styles.linkBtn}
onClick={() => switchView('signup')} onClick={() => switchView('signup')}
disabled={isSubmitting} disabled={isSubmitting}
> >
Sign up {t('login.signUp')}
</button> </button>
</p> </p>
</> </>
@@ -347,8 +351,8 @@ export function LoginPage() {
{view === 'signup' && ( {view === 'signup' && (
<> <>
<h1 className={styles.title}>Create account</h1> <h1 className={styles.title}>{t('signup.title')}</h1>
<p className={styles.subtitle}>Register as a customer of {tenantDomain}</p> <p className={styles.subtitle}>{t('signup.subtitle', { domain: tenantDomain })}</p>
<form className={styles.form} onSubmit={handleSignup}> <form className={styles.form} onSubmit={handleSignup}>
{error && ( {error && (
@@ -360,13 +364,13 @@ export function LoginPage() {
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-first">First name</label> <label htmlFor="signup-first">{t('signup.firstName')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} /> <User size={18} className={styles.inputIcon} />
<input <input
id="signup-first" id="signup-first"
type="text" type="text"
placeholder="First name" placeholder={t('signup.firstName')}
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
required required
@@ -376,13 +380,13 @@ export function LoginPage() {
</div> </div>
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-last">Last name</label> <label htmlFor="signup-last">{t('signup.lastName')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} /> <User size={18} className={styles.inputIcon} />
<input <input
id="signup-last" id="signup-last"
type="text" type="text"
placeholder="Last name" placeholder={t('signup.lastName')}
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
required required
@@ -394,7 +398,7 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-phone">Mobile number</label> <label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -411,13 +415,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-password">Password</label> <label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="signup-password" id="signup-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Choose a password" placeholder={t('signup.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -428,7 +432,7 @@ export function LoginPage() {
type="button" type="button"
className={styles.togglePassword} className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting} disabled={isSubmitting}
> >
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -437,13 +441,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="signup-confirm">Confirm password</label> <label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="signup-confirm" id="signup-confirm"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Repeat your password" placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
required required
@@ -454,19 +458,19 @@ export function LoginPage() {
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create account'} {isSubmitting ? t('signup.creating') : t('signup.create')}
</button> </button>
</form> </form>
<p className={styles.footerText}> <p className={styles.footerText}>
Already have an account?{' '} {t('signup.hasAccount')}{' '}
<button <button
type="button" type="button"
className={styles.linkBtn} className={styles.linkBtn}
onClick={() => switchView('login')} onClick={() => switchView('login')}
disabled={isSubmitting} disabled={isSubmitting}
> >
Sign in {t('signup.signIn')}
</button> </button>
</p> </p>
</> </>
@@ -481,14 +485,12 @@ export function LoginPage() {
disabled={isSubmitting} disabled={isSubmitting}
> >
<ArrowLeft size={18} /> <ArrowLeft size={18} />
Back to sign in {t('forgot.back')}
</button> </button>
<h1 className={styles.title}>Forgot password</h1> <h1 className={styles.title}>{t('forgot.title')}</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
{smsStep === 'phone' {smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
? 'We will send a verification code via SMS'
: 'Enter the code and your new password'}
</p> </p>
<form className={styles.form} onSubmit={handleResetPassword}> <form className={styles.form} onSubmit={handleResetPassword}>
@@ -502,7 +504,7 @@ export function LoginPage() {
{smsStep === 'phone' ? ( {smsStep === 'phone' ? (
<> <>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="forgot-phone">Mobile number</label> <label htmlFor="forgot-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -524,19 +526,19 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? 'Sending...' : 'Send SMS code'} {isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
</button> </button>
</> </>
) : ( ) : (
<> <>
{codeSent && ( {codeSent && (
<p className={styles.codeHint}> <p className={styles.codeHint}>
Verification code sent to <strong>{phone}</strong> {t('common.codeSent', { phone })}
</p> </p>
)} )}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="forgot-code">SMS verification code</label> <label htmlFor="forgot-code">{t('forgot.code')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} /> <KeyRound size={18} className={styles.inputIcon} />
<input <input
@@ -554,13 +556,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <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}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="forgot-new-password" id="forgot-new-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Enter new password" placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
@@ -572,7 +574,7 @@ export function LoginPage() {
<div className={styles.resendRow}> <div className={styles.resendRow}>
{countdown > 0 ? ( {countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span> <span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
) : ( ) : (
<button <button
type="button" type="button"
@@ -580,13 +582,13 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
Resend SMS code {t('common.resend')}
</button> </button>
)} )}
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Verifying...' : 'Reset password'} {isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button> </button>
</> </>
)} )}
@@ -603,14 +605,12 @@ export function LoginPage() {
disabled={isSubmitting} disabled={isSubmitting}
> >
<ArrowLeft size={18} /> <ArrowLeft size={18} />
Back to sign in {t('otp.back')}
</button> </button>
<h1 className={styles.title}>One-time login</h1> <h1 className={styles.title}>{t('otp.title')}</h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
{smsStep === 'phone' {smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
? 'Verify your mobile number with a one-time SMS code'
: 'Enter the SMS code and your password'}
</p> </p>
<form className={styles.form} onSubmit={handleOtpLogin}> <form className={styles.form} onSubmit={handleOtpLogin}>
@@ -623,7 +623,7 @@ export function LoginPage() {
{smsStep === 'phone' ? ( {smsStep === 'phone' ? (
<> <>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="otp-phone">Mobile number</label> <label htmlFor="otp-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} /> <Smartphone size={18} className={styles.inputIcon} />
<input <input
@@ -645,19 +645,19 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? 'Sending...' : 'Send SMS code'} {isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
</button> </button>
</> </>
) : ( ) : (
<> <>
{codeSent && ( {codeSent && (
<p className={styles.codeHint}> <p className={styles.codeHint}>
Verification code sent to <strong>{phone}</strong> {t('common.codeSent', { phone })}
</p> </p>
)} )}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="otp-code">SMS verification code</label> <label htmlFor="otp-code">{t('otp.code')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} /> <KeyRound size={18} className={styles.inputIcon} />
<input <input
@@ -675,13 +675,13 @@ export function LoginPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="otp-password">Password</label> <label htmlFor="otp-password">{t('otp.password')}</label>
<div className={styles.inputWrap}> <div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} /> <Lock size={18} className={styles.inputIcon} />
<input <input
id="otp-password" id="otp-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="Your account password" placeholder={t('otp.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -693,7 +693,7 @@ export function LoginPage() {
<div className={styles.resendRow}> <div className={styles.resendRow}>
{countdown > 0 ? ( {countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span> <span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
) : ( ) : (
<button <button
type="button" type="button"
@@ -701,13 +701,13 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
Resend SMS code {t('common.resend')}
</button> </button>
)} )}
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'} {isSubmitting ? t('login.signingIn') : t('login.signIn')}
</button> </button>
</> </>
)} )}
@@ -71,7 +71,7 @@
.th, .th,
.td { .td {
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16); border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle; vertical-align: middle;
} }
@@ -217,21 +217,22 @@
} }
.thActions { .thActions {
text-align: center; text-align: end;
white-space: nowrap; white-space: nowrap;
} }
.tdActions { .tdActions {
text-align: right; text-align: end;
white-space: nowrap; white-space: nowrap;
padding-right: 10px; padding-inline: 10px;
} }
.rowActions { .rowActions {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0; gap: 0;
width: 100%;
} }
.actionBtn { .actionBtn {
+35 -23
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Breadcrumbs, Pagination } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination, useLocale } from '@meshkee/dashboard-ui'
import { OrderItemsModal } from '../components/OrderItemsModal' import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow' import { OrderRow } from '../components/OrderRow'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
listOrders, listOrders,
@@ -16,12 +17,16 @@ const PAGE_SIZE = 20
const COLUMN_COUNT = 7 const COLUMN_COUNT = 7
export function OrdersPage() { export function OrdersPage() {
const t = useT()
const { locale } = useLocale()
const [data, setData] = useState<OrdersListResponse | null>(null) const [data, setData] = useState<OrdersListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [viewOrder, setViewOrder] = useState<Order | null>(null) const [viewOrder, setViewOrder] = useState<Order | null>(null)
const processSteps = DEFAULT_ORDER_PROCESS_STEPS
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -35,7 +40,7 @@ export function OrdersPage() {
setData(response) setData(response)
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load orders.') setError(err instanceof ApiError ? err.message : t('orders.error.load'))
} finally { } finally {
if (!controller.signal.aborted) setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
@@ -43,7 +48,7 @@ export function OrdersPage() {
void load() void load()
return () => controller.abort() return () => controller.abort()
}, [page]) }, [page, t])
const totalPages = useMemo(() => { const totalPages = useMemo(() => {
const total = data?.total ?? 0 const total = data?.total ?? 0
@@ -62,27 +67,29 @@ export function OrdersPage() {
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Orders' }]} /> <Breadcrumbs items={[{ label: t('nav.home'), href: '/' }, { label: t('orders.title') }]} />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Orders</h2> <h2 className={pageStyles.pageTitle}>{t('orders.title')}</h2>
<p className={pageStyles.pageSubtitle}>View your order history and details.</p> <p className={pageStyles.pageSubtitle}>{t('orders.subtitle')}</p>
</div> </div>
</div> </div>
<div className={styles.tablePanel}> <div className={styles.tablePanel}>
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Order list</div> <div className={styles.tableHeaderTitle}>{t('orders.listTitle')}</div>
<div className={styles.meta}> <div className={styles.meta}>
{data ? ( {data ? (
data.total > 0 ? ( data.total > 0 ? (
<> t('orders.showing', {
Showing {showingFrom} - {showingTo} of {data.total} from: showingFrom,
</> to: showingTo,
total: data.total,
})
) : ( ) : (
'No orders' t('orders.none')
) )
) : ( ) : (
' ' ' '
@@ -92,7 +99,7 @@ export function OrdersPage() {
{error && <div className={styles.errorBanner}>{error}</div>} {error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table}> <table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<colgroup> <colgroup>
<col className={styles.colOrderId} /> <col className={styles.colOrderId} />
<col className={styles.colItems} /> <col className={styles.colItems} />
@@ -104,20 +111,20 @@ export function OrdersPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className={styles.th}>Order ID</th> <th className={styles.th}>{t('orders.col.orderId')}</th>
<th className={styles.th}>Items</th> <th className={styles.th}>{t('orders.col.items')}</th>
<th className={styles.th}>Total cost</th> <th className={styles.th}>{t('orders.col.total')}</th>
<th className={styles.th}>Date &amp; time</th> <th className={styles.th}>{t('orders.col.date')}</th>
<th className={styles.th}>Step</th> <th className={styles.th}>{t('orders.col.step')}</th>
<th className={styles.th}>Registered by</th> <th className={styles.th}>{t('orders.col.source')}</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th> <th className={`${styles.th} ${styles.thActions}`}>{t('orders.col.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
Loading orders... {t('orders.loading')}
</td> </td>
</tr> </tr>
)} )}
@@ -125,7 +132,7 @@ export function OrdersPage() {
{!loading && data?.items.length === 0 && ( {!loading && data?.items.length === 0 && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
You have no orders yet. {t('orders.empty')}
</td> </td>
</tr> </tr>
)} )}
@@ -135,7 +142,7 @@ export function OrdersPage() {
<OrderRow <OrderRow
key={order.id} key={order.id}
order={order} order={order}
processSteps={DEFAULT_ORDER_PROCESS_STEPS} processSteps={processSteps}
onViewItems={setViewOrder} onViewItems={setViewOrder}
/> />
))} ))}
@@ -145,7 +152,12 @@ export function OrdersPage() {
{data && data.total > PAGE_SIZE && ( {data && data.total > PAGE_SIZE && (
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data.total} total {t('orders.pageMeta', {
page,
totalPages,
pageSize: PAGE_SIZE,
total: data.total,
})}
</div> </div>
<Pagination <Pagination
currentPage={page} currentPage={page}
+57 -24
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Breadcrumbs } from '@meshkee/dashboard-ui' import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useToast } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { updateProfile } from '../services/authService' import { updateProfile } from '../services/authService'
@@ -11,9 +11,12 @@ import styles from './ProfilePage.module.css'
export function ProfilePage() { export function ProfilePage() {
const { user, setUser } = useAuth() const { user, setUser } = useAuth()
const { showToast } = useToast() const { showToast } = useToast()
const t = useT()
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('') const [lastName, setLastName] = useState('')
const [firstNameEn, setFirstNameEn] = useState('')
const [lastNameEn, setLastNameEn] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [landline, setLandline] = useState('') const [landline, setLandline] = useState('')
const [backupPhone, setBackupPhone] = useState('') const [backupPhone, setBackupPhone] = useState('')
@@ -29,6 +32,8 @@ export function ProfilePage() {
setFirstName(user.firstName ?? '') setFirstName(user.firstName ?? '')
setLastName(user.lastName ?? '') setLastName(user.lastName ?? '')
setFirstNameEn(user.firstNameEn ?? '')
setLastNameEn(user.lastNameEn ?? '')
setEmail(user.email ?? '') setEmail(user.email ?? '')
setLandline(user.profile.landline ?? '') setLandline(user.profile.landline ?? '')
setBackupPhone(user.profile.backupPhone ?? '') setBackupPhone(user.profile.backupPhone ?? '')
@@ -47,6 +52,8 @@ export function ProfilePage() {
const result = await updateProfile({ const result = await updateProfile({
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
firstNameEn: firstNameEn.trim(),
lastNameEn: lastNameEn.trim(),
email: email.trim() || undefined, email: email.trim() || undefined,
landline: landline.trim(), landline: landline.trim(),
backupPhone: backupPhone.trim(), backupPhone: backupPhone.trim(),
@@ -57,10 +64,10 @@ export function ProfilePage() {
}) })
setUser(result.user) setUser(result.user)
showToast('Profile updated successfully.', 'success') showToast(t('profile.toast.success'), 'success')
} catch (err) { } catch (err) {
const message = const message =
err instanceof ApiError ? err.message : 'Unable to update profile. Please try again.' err instanceof ApiError ? err.message : t('profile.error.update')
setError(message) setError(message)
showToast(message, 'error') showToast(message, 'error')
} finally { } finally {
@@ -70,14 +77,12 @@ export function ProfilePage() {
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Profile' }]} /> <Breadcrumbs items={[{ label: t('nav.home'), href: '/' }, { label: t('profile.title') }]} />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Profile</h2> <h2 className={pageStyles.pageTitle}>{t('profile.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('profile.subtitle')}</p>
Update your personal information and contact details.
</p>
</div> </div>
</div> </div>
@@ -89,10 +94,10 @@ export function ProfilePage() {
)} )}
<section className={styles.section}> <section className={styles.section}>
<h3 className={styles.sectionTitle}>Account</h3> <h3 className={styles.sectionTitle}>{t('profile.section.account')}</h3>
<div className={styles.grid}> <div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-cell">Mobile number</label> <label htmlFor="profile-cell">{t('profile.mobile')}</label>
<input <input
id="profile-cell" id="profile-cell"
type="text" type="text"
@@ -101,37 +106,65 @@ export function ProfilePage() {
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-email">Email</label> <label htmlFor="profile-email">{t('profile.email')}</label>
<input <input
id="profile-email" id="profile-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com" placeholder={t('profile.emailPlaceholder')}
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-first">First name</label> <label htmlFor="profile-first">{t('profile.firstName')}</label>
<input <input
id="profile-first" id="profile-first"
type="text" type="text"
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
required required
dir="rtl"
lang="fa"
className="faText"
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-last">Last name</label> <label htmlFor="profile-last">{t('profile.lastName')}</label>
<input <input
id="profile-last" id="profile-last"
type="text" type="text"
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
required required
dir="rtl"
lang="fa"
className="faText"
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-landline">Landline</label> <label htmlFor="profile-first-en">{t('profile.firstNameEn')}</label>
<input
id="profile-first-en"
type="text"
value={firstNameEn}
onChange={(e) => setFirstNameEn(e.target.value)}
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-last-en">{t('profile.lastNameEn')}</label>
<input
id="profile-last-en"
type="text"
value={lastNameEn}
onChange={(e) => setLastNameEn(e.target.value)}
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-landline">{t('profile.landline')}</label>
<input <input
id="profile-landline" id="profile-landline"
type="text" type="text"
@@ -141,7 +174,7 @@ export function ProfilePage() {
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="profile-backup-phone">Backup phone number</label> <label htmlFor="profile-backup-phone">{t('profile.backupPhone')}</label>
<input <input
id="profile-backup-phone" id="profile-backup-phone"
type="tel" type="tel"
@@ -155,10 +188,10 @@ export function ProfilePage() {
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h3 className={styles.sectionTitle}>About</h3> <h3 className={styles.sectionTitle}>{t('profile.section.about')}</h3>
<div className={styles.grid}> <div className={styles.grid}>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label htmlFor="profile-about">About</label> <label htmlFor="profile-about">{t('profile.about')}</label>
<textarea <textarea
id="profile-about" id="profile-about"
value={about} value={about}
@@ -170,10 +203,10 @@ export function ProfilePage() {
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h3 className={styles.sectionTitle}>Social</h3> <h3 className={styles.sectionTitle}>{t('profile.section.social')}</h3>
<div className={styles.grid}> <div className={styles.grid}>
<div className={`${styles.field} ${styles.col4}`}> <div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="profile-instagram">Instagram</label> <label htmlFor="profile-instagram">{t('profile.instagram')}</label>
<input <input
id="profile-instagram" id="profile-instagram"
type="text" type="text"
@@ -182,7 +215,7 @@ export function ProfilePage() {
/> />
</div> </div>
<div className={`${styles.field} ${styles.col4}`}> <div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="profile-telegram">Telegram</label> <label htmlFor="profile-telegram">{t('profile.telegram')}</label>
<input <input
id="profile-telegram" id="profile-telegram"
type="text" type="text"
@@ -191,7 +224,7 @@ export function ProfilePage() {
/> />
</div> </div>
<div className={`${styles.field} ${styles.col4}`}> <div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="profile-linkedin">LinkedIn</label> <label htmlFor="profile-linkedin">{t('profile.linkedin')}</label>
<input <input
id="profile-linkedin" id="profile-linkedin"
type="text" type="text"
@@ -204,7 +237,7 @@ export function ProfilePage() {
<div className={styles.actions}> <div className={styles.actions}>
<button type="submit" className={styles.saveBtn} disabled={isSaving}> <button type="submit" className={styles.saveBtn} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save changes'} {isSaving ? t('profile.saving') : t('profile.save')}
</button> </button>
</div> </div>
</form> </form>
@@ -45,6 +45,8 @@ export async function updateProfile(
payload: Partial<UserProfile> & { payload: Partial<UserProfile> & {
firstName?: string firstName?: string
lastName?: string lastName?: string
firstNameEn?: string
lastNameEn?: string
email?: string email?: string
}, },
) { ) {
@@ -45,6 +45,7 @@ export interface Order {
status: OrderStatus status: OrderStatus
processStepId: string processStepId: string
processStepLabel?: string | null processStepLabel?: string | null
processStepLabelFa?: string | null
processStepColor?: string | null processStepColor?: string | null
source: OrderSource source: OrderSource
subtotal: number subtotal: number
@@ -1,4 +1,5 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors' import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export interface ResolvedTenant { export interface ResolvedTenant {
@@ -8,6 +9,7 @@ export interface ResolvedTenant {
slug: string slug: string
domain: string domain: string
primaryColor: BusinessPrimaryColorId primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale
logoUrl?: string | null logoUrl?: string | null
faviconUrl?: string | null faviconUrl?: string | null
} }
@@ -11,6 +11,10 @@ const CSS_VAR_DEFAULTS: Record<string, string> = {
'--primary-dark': '#2563eb', '--primary-dark': '#2563eb',
'--primary-rgb': '59 130 246', '--primary-rgb': '59 130 246',
'--primary-dark-rgb': '37 99 235', '--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) { 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-dark', tokens.primaryDark)
root.style.setProperty('--primary-rgb', tokens.primaryRgb) root.style.setProperty('--primary-rgb', tokens.primaryRgb)
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb) 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() { export function resetBusinessPrimaryColor() {
@@ -20,8 +20,27 @@ export type BusinessPrimaryColorTokens = {
primaryGlow: string primaryGlow: string
primaryRgb: string primaryRgb: string
primaryDarkRgb: 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< export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId, BusinessPrimaryColorId,
BusinessPrimaryColorTokens BusinessPrimaryColorTokens
@@ -34,6 +53,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#ef4444', primaryGlow: '#ef4444',
primaryRgb: '239 68 68', primaryRgb: '239 68 68',
primaryDarkRgb: '220 38 38', primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
}, },
yellow: { yellow: {
label: 'Yellow', label: 'Yellow',
@@ -43,6 +63,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#eab308', primaryGlow: '#eab308',
primaryRgb: '234 179 8', primaryRgb: '234 179 8',
primaryDarkRgb: '202 138 4', primaryDarkRgb: '202 138 4',
...PURPLE_ACCENT,
}, },
black: { black: {
label: 'Black', label: 'Black',
@@ -52,6 +73,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#334155', primaryGlow: '#334155',
primaryRgb: '30 41 59', primaryRgb: '30 41 59',
primaryDarkRgb: '15 23 42', primaryDarkRgb: '15 23 42',
...CYAN_ACCENT,
}, },
cyan: { cyan: {
label: 'Cyan', label: 'Cyan',
@@ -61,6 +83,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#06b6d4', primaryGlow: '#06b6d4',
primaryRgb: '6 182 212', primaryRgb: '6 182 212',
primaryDarkRgb: '8 145 178', primaryDarkRgb: '8 145 178',
...PURPLE_ACCENT,
}, },
purple: { purple: {
label: 'Purple', label: 'Purple',
@@ -70,6 +93,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#a855f7', primaryGlow: '#a855f7',
primaryRgb: '168 85 247', primaryRgb: '168 85 247',
primaryDarkRgb: '147 51 234', primaryDarkRgb: '147 51 234',
...CYAN_ACCENT,
}, },
'light-blue': { 'light-blue': {
label: 'Light Blue', label: 'Light Blue',
@@ -79,6 +103,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#38bdf8', primaryGlow: '#38bdf8',
primaryRgb: '56 189 248', primaryRgb: '56 189 248',
primaryDarkRgb: '14 165 233', primaryDarkRgb: '14 165 233',
...CYAN_ACCENT,
}, },
'dark-blue': { 'dark-blue': {
label: 'Dark Blue', label: 'Dark Blue',
@@ -88,6 +113,7 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryGlow: '#3b82f6', primaryGlow: '#3b82f6',
primaryRgb: '59 130 246', primaryRgb: '59 130 246',
primaryDarkRgb: '37 99 235', primaryDarkRgb: '37 99 235',
...CYAN_ACCENT,
}, },
} }
+60 -6
View File
@@ -1,23 +1,74 @@
export interface OrderProcessStep { export interface OrderProcessStep {
id: string id: string
label: string label: string
labelFa: string
color: string color: string
} }
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [ 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: 'processing',
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' }, label: 'Under processing',
{ id: 'delivered', label: 'Delivered', color: '#22C55E' }, 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 defaultFaForLabel(label: string | null | undefined) {
const trimmed = label?.trim()
if (!trimmed) return undefined
return DEFAULT_ORDER_PROCESS_STEPS.find(
(step) => step.label.toLowerCase() === trimmed.toLowerCase(),
)?.labelFa
}
export function stepLabel( export function stepLabel(
steps: OrderProcessStep[], steps: OrderProcessStep[],
processStepId: string, processStepId: string,
processStepLabel?: string | null, processStepLabel?: string | null,
processStepLabelFa?: string | null,
locale: 'en' | 'fa' = 'en',
) { ) {
const step =
steps.find((item) => item.id === processStepId) ??
DEFAULT_ORDER_PROCESS_STEPS.find((item) => item.id === processStepId)
if (locale === 'fa') {
let fa = processStepLabelFa?.trim() || step?.labelFa?.trim()
// Older API payloads sometimes echoed the English label as labelFa.
if (fa && processStepLabel?.trim() && fa === processStepLabel.trim()) {
fa = undefined
}
fa =
fa ||
defaultFaForLabel(processStepLabel) ||
defaultFaForLabel(step?.label)
if (fa) return fa
}
if (processStepLabel?.trim()) return processStepLabel.trim() if (processStepLabel?.trim()) return processStepLabel.trim()
return steps.find((step) => step.id === processStepId)?.label ?? processStepId return step?.label ?? processStepId
} }
export function stepColor( export function stepColor(
@@ -28,7 +79,10 @@ export function stepColor(
if (processStepColor?.trim()) return processStepColor.trim() if (processStepColor?.trim()) return processStepColor.trim()
const index = steps.findIndex((step) => step.id === processStepId) const index = steps.findIndex((step) => step.id === processStepId)
const step = index >= 0 ? steps[index] : steps[0] const step =
(index >= 0 ? steps[index] : undefined) ??
DEFAULT_ORDER_PROCESS_STEPS.find((item) => item.id === processStepId) ??
steps[0]
if (step?.color) return step.color if (step?.color) return step.color
@@ -1,3 +1,6 @@
export function formatVariantCount(count: number): string { export function formatVariantCount(count: number, locale: 'en' | 'fa' = 'en'): string {
if (locale === 'fa') {
return count === 1 ? '۱ تنوع' : `${count} تنوع`
}
return count === 1 ? '1 variant' : `${count} variants` return count === 1 ? '1 variant' : `${count} variants`
} }
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link <link
@@ -89,13 +89,6 @@
cursor: pointer; cursor: pointer;
} }
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo { .profileInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -18,8 +18,6 @@ export function Header() {
user?.cellNumber || user?.cellNumber ||
'Super Admin' 'Super Admin'
const avatarSeed = encodeURIComponent(user?.cellNumber ?? 'admin')
useEffect(() => { useEffect(() => {
if (!menuOpen) return if (!menuOpen) return
@@ -88,11 +86,6 @@ export function Header() {
aria-expanded={menuOpen} aria-expanded={menuOpen}
aria-haspopup="menu" aria-haspopup="menu"
> >
<img
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${avatarSeed}`}
alt={displayName}
className={styles.avatar}
/>
<div className={styles.profileInfo}> <div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span> <span className={styles.name}>{displayName}</span>
<span className={styles.role}>Super Administrator</span> <span className={styles.role}>Super Administrator</span>
@@ -71,3 +71,12 @@
transform: translateX(2px); transform: translateX(2px);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35); box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
} }
:global([dir='rtl']) .arrowBtn {
transform: scaleX(-1);
}
:global([dir='rtl']) .card:hover .arrowBtn {
transform: scaleX(-1) translateX(2px);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
+6 -5
View File
@@ -36,8 +36,9 @@
--field-padding-y: 9px; --field-padding-y: 9px;
--field-padding-x: 12px; --field-padding-x: 12px;
--field-height: 38px; --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-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
--card-hover-lift: -4px; --card-hover-lift: -4px;
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14); --card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease; --card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
@@ -50,7 +51,7 @@ body,
} }
body { body {
font-family: var(--font-en); font-family: var(--font-ui);
color: var(--text-primary); color: var(--text-primary);
background: background:
radial-gradient(ellipse 80% 60% at 10% 0%, rgba(99, 102, 241, 0.18) 0%, transparent 55%), radial-gradient(ellipse 80% 60% at 10% 0%, rgba(99, 102, 241, 0.18) 0%, transparent 55%),
@@ -126,7 +127,7 @@ select[multiple] option {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']), input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
textarea { textarea {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-size: var(--field-font-size); font-size: var(--field-font-size);
outline: none; outline: none;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
@@ -147,12 +148,12 @@ textarea:focus {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder, input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder { textarea::placeholder {
font-family: var(--font-en); font-family: var(--font-ui);
} }
[dir='rtl'], [dir='rtl'],
:lang(fa), :lang(fa),
.faText { .faText {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-ui);
font-weight: 400; font-weight: 400;
} }
@@ -402,6 +402,47 @@
padding-right: 8px; padding-right: 8px;
} }
.thLocale,
.tdLocale {
width: 1%;
white-space: nowrap;
text-align: center;
}
.tdLocale {
padding-left: 8px;
padding-right: 8px;
}
.localeSelect {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y)
var(--field-padding-x);
font-size: var(--field-font-size);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background-color: rgba(255, 255, 255, 0.7);
color: var(--text-primary);
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
.localeSelect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.localeSelect:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.td { .td {
font-size: 13px; font-size: 13px;
color: var(--text-primary); color: var(--text-primary);
+110 -4
View File
@@ -55,7 +55,10 @@ import {
} from '../utils/businessPrimaryColors' } from '../utils/businessPrimaryColors'
import { import {
getBusinessSettings, getBusinessSettings,
updateBusinessBranding,
updateBusinessDefaultLocale,
updateBusinessPrimaryColor, updateBusinessPrimaryColor,
type DashboardLocale,
} from '../services/businessSettingsService' } from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories' import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui' import { Pagination } from '@meshkee/dashboard-ui'
@@ -63,6 +66,11 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css' import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 10 const DEFAULT_PAGE_SIZE = 10
const DEFAULT_LOCALE: DashboardLocale = 'fa'
function normalizeDefaultLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
}
function formatDate(value: string) { function formatDate(value: string) {
const d = new Date(value) const d = new Date(value)
@@ -190,6 +198,7 @@ export function BusinessesPage() {
const [editPrimaryColor, setEditPrimaryColor] = useState<BusinessPrimaryColorId>( const [editPrimaryColor, setEditPrimaryColor] = useState<BusinessPrimaryColorId>(
DEFAULT_BUSINESS_PRIMARY_COLOR_ID, DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
) )
const [editDefaultLocale, setEditDefaultLocale] = useState<DashboardLocale>(DEFAULT_LOCALE)
const [editLoadingSettings, setEditLoadingSettings] = useState(false) const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false) const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('') const [editError, setEditError] = useState('')
@@ -216,6 +225,7 @@ export function BusinessesPage() {
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null) const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null) const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
const [migrateOpen, setMigrateOpen] = useState(false) const [migrateOpen, setMigrateOpen] = useState(false)
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null) const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
@@ -327,6 +337,7 @@ export function BusinessesPage() {
setEditBusiness(b) setEditBusiness(b)
setEditName(b.name) setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor)) setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditDefaultLocale(normalizeDefaultLocale(b.defaultLocale))
setEditError('') setEditError('')
setEditOpen(true) setEditOpen(true)
setEditLoadingSettings(true) setEditLoadingSettings(true)
@@ -335,6 +346,7 @@ export function BusinessesPage() {
void getBusinessSettings(b.id, controller.signal) void getBusinessSettings(b.id, controller.signal)
.then((data) => { .then((data) => {
setEditPrimaryColor(data.settings.branding.primaryColor) setEditPrimaryColor(data.settings.branding.primaryColor)
setEditDefaultLocale(normalizeDefaultLocale(data.settings.branding.defaultLocale))
}) })
.catch((err) => { .catch((err) => {
if (isAbortError(err)) return if (isAbortError(err)) return
@@ -405,20 +417,76 @@ export function BusinessesPage() {
} }
} }
async function handleDefaultLocaleChange(b: BusinessListItem, defaultLocale: DashboardLocale) {
const currentLocale = normalizeDefaultLocale(b.defaultLocale)
if (currentLocale === defaultLocale) return
setSavingLocaleId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, defaultLocale } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditDefaultLocale(defaultLocale)
}
try {
await updateBusinessDefaultLocale(b.id, defaultLocale)
showToast(`Default language updated for "${b.name}".`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, defaultLocale: currentLocale } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditDefaultLocale(currentLocale)
}
showToast(
err instanceof ApiError ? err.message : 'Unable to update default language.',
'error',
)
} finally {
setSavingLocaleId(null)
}
}
async function submitEdit() { async function submitEdit() {
if (!editBusiness) return if (!editBusiness) return
setEditSubmitting(true) setEditSubmitting(true)
setEditError('') setEditError('')
try { try {
await updateBusiness(editBusiness.id, { name: editName }) await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor) await updateBusinessBranding(editBusiness.id, {
primaryColor: editPrimaryColor,
defaultLocale: editDefaultLocale,
})
setData((prev) => { setData((prev) => {
if (!prev) return prev if (!prev) return prev
return { return {
...prev, ...prev,
items: prev.items.map((item) => items: prev.items.map((item) =>
item.id === editBusiness.id item.id === editBusiness.id
? { ...item, name: editName.trim(), primaryColor: editPrimaryColor } ? {
...item,
name: editName.trim(),
primaryColor: editPrimaryColor,
defaultLocale: editDefaultLocale,
}
: item, : item,
), ),
} }
@@ -828,13 +896,14 @@ export function BusinessesPage() {
<th className={styles.th}>Domain</th> <th className={styles.th}>Domain</th>
<th className={styles.th}>Owner</th> <th className={styles.th}>Owner</th>
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th> <th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th> <th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={styles.td} colSpan={6}> <td className={styles.td} colSpan={7}>
Loading... Loading...
</td> </td>
</tr> </tr>
@@ -842,7 +911,7 @@ export function BusinessesPage() {
{!loading && data?.items?.length === 0 && ( {!loading && data?.items?.length === 0 && (
<tr> <tr>
<td className={styles.td} colSpan={6}> <td className={styles.td} colSpan={7}>
No results found. No results found.
</td> </td>
</tr> </tr>
@@ -900,6 +969,23 @@ export function BusinessesPage() {
} }
/> />
</td> </td>
<td className={`${styles.td} ${styles.tdLocale}`}>
<select
className={styles.localeSelect}
value={normalizeDefaultLocale(b.defaultLocale)}
disabled={savingLocaleId === b.id}
aria-label={`Default language for ${b.name}`}
onChange={(e) =>
void handleDefaultLocaleChange(
b,
normalizeDefaultLocale(e.target.value),
)
}
>
<option value="fa">FA</option>
<option value="en">EN</option>
</select>
</td>
<td className={`${styles.td} ${styles.tdActions}`}> <td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}> <div className={styles.rowActions}>
<span className={styles.toggleInActions}> <span className={styles.toggleInActions}>
@@ -1058,6 +1144,26 @@ export function BusinessesPage() {
/> />
)} )}
</div> </div>
<div className={styles.field}>
<label htmlFor="edit-default-locale">Default dashboard language</label>
{editLoadingSettings ? (
<p className={styles.helperText}>Loading language...</p>
) : (
<select
id="edit-default-locale"
className={styles.localeSelect}
value={editDefaultLocale}
disabled={editSubmitting}
onChange={(e) => setEditDefaultLocale(normalizeDefaultLocale(e.target.value))}
>
<option value="fa">Farsi (FA)</option>
<option value="en">English (EN)</option>
</select>
)}
<p className={styles.helperText}>
Business and customer dashboards open in this language.
</p>
</div>
<div style={{ height: 12 }} /> <div style={{ height: 12 }} />
<div className={styles.actionsRow}> <div className={styles.actionsRow}>
<button <button
+32 -2
View File
@@ -13,6 +13,8 @@ function buildFormData(user: ReturnType<typeof useAuth>['user']): ProfileFormDat
return { return {
firstName: user?.firstName ?? '', firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '', lastName: user?.lastName ?? '',
firstNameEn: user?.firstNameEn ?? '',
lastNameEn: user?.lastNameEn ?? '',
cellNumber: user ? formatCellForDisplay(user.cellNumber) : '', cellNumber: user ? formatCellForDisplay(user.cellNumber) : '',
email: user?.email ?? '', email: user?.email ?? '',
about: user?.profile?.about ?? emptyProfile.about, about: user?.profile?.about ?? emptyProfile.about,
@@ -85,23 +87,51 @@ export function ProfilePage() {
<h3 className={styles.sectionTitle}>General</h3> <h3 className={styles.sectionTitle}>General</h3>
<div className={styles.grid}> <div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="firstName">Name</label> <label htmlFor="firstName">First name (FA)</label>
<input <input
id="firstName" id="firstName"
type="text" type="text"
value={form.firstName} value={form.firstName}
onChange={(e) => updateField('firstName', e.target.value)} onChange={(e) => updateField('firstName', e.target.value)}
placeholder="First name" placeholder="First name"
dir="rtl"
lang="fa"
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="lastName">Last name</label> <label htmlFor="lastName">Last name (FA)</label>
<input <input
id="lastName" id="lastName"
type="text" type="text"
value={form.lastName} value={form.lastName}
onChange={(e) => updateField('lastName', e.target.value)} onChange={(e) => updateField('lastName', e.target.value)}
placeholder="Last name" placeholder="Last name"
dir="rtl"
lang="fa"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="firstNameEn">First name (EN)</label>
<input
id="firstNameEn"
type="text"
value={form.firstNameEn}
onChange={(e) => updateField('firstNameEn', e.target.value)}
placeholder="First name"
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="lastNameEn">Last name (EN)</label>
<input
id="lastNameEn"
type="text"
value={form.lastNameEn}
onChange={(e) => updateField('lastNameEn', e.target.value)}
placeholder="Last name"
dir="ltr"
lang="en"
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
@@ -1,8 +1,11 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors' import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export type DashboardLocale = 'en' | 'fa'
export interface BrandingSettings { export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId primaryColor: BusinessPrimaryColorId
defaultLocale: DashboardLocale
} }
export interface BusinessSettings { export interface BusinessSettings {
@@ -41,3 +44,27 @@ export async function updateBusinessPrimaryColor(
}, },
}) })
} }
export async function updateBusinessDefaultLocale(
businessId: string,
defaultLocale: DashboardLocale,
) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
method: 'PATCH',
auth: true,
body: {
branding: { defaultLocale },
},
})
}
export async function updateBusinessBranding(
businessId: string,
branding: Partial<BrandingSettings>,
) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
method: 'PATCH',
auth: true,
body: { branding },
})
}
@@ -9,6 +9,8 @@ export async function updateProfile(data: ProfileFormData) {
body: { body: {
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, lastName: data.lastName,
firstNameEn: data.firstNameEn,
lastNameEn: data.lastNameEn,
email: data.email || null, email: data.email || null,
about: data.about, about: data.about,
city: data.city, city: data.city,
+2
View File
@@ -17,6 +17,8 @@ export interface AuthUser {
email: string | null email: string | null
firstName: string | null firstName: string | null
lastName: string | null lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
cellVerifiedAt: string | null cellVerifiedAt: string | null
roles: string[] roles: string[]
dashboard: DashboardType dashboard: DashboardType
+2
View File
@@ -1,4 +1,5 @@
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors' import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { DashboardLocale } from '../services/businessSettingsService'
export interface BusinessOwnerInfo { export interface BusinessOwnerInfo {
name: string | null name: string | null
@@ -21,6 +22,7 @@ export interface BusinessListItem {
ownerCellNumber: string | null ownerCellNumber: string | null
isActive: boolean isActive: boolean
primaryColor: BusinessPrimaryColorId primaryColor: BusinessPrimaryColorId
defaultLocale: DashboardLocale
oldBusinessId: string | null oldBusinessId: string | null
} }
+2
View File
@@ -12,6 +12,8 @@ export interface UserProfile {
export interface ProfileFormData { export interface ProfileFormData {
firstName: string firstName: string
lastName: string lastName: string
firstNameEn: string
lastNameEn: string
cellNumber: string cellNumber: string
email: string email: string
about: string about: string
@@ -0,0 +1,42 @@
import {
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
getBusinessPrimaryColorTokens,
type BusinessPrimaryColorId,
} from './businessPrimaryColors'
const CSS_VAR_DEFAULTS: Record<string, string> = {
'--primary': '#3b82f6',
'--primary-glow': '#3b82f6',
'--primary-light': '#dbeafe',
'--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) {
const root = document.documentElement
const tokens = getBusinessPrimaryColorTokens(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
root.style.setProperty('--primary', tokens.primary)
root.style.setProperty('--primary-glow', tokens.primaryGlow)
root.style.setProperty('--primary-light', tokens.primaryLight)
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() {
const root = document.documentElement
for (const [name, value] of Object.entries(CSS_VAR_DEFAULTS)) {
root.style.setProperty(name, value)
}
}
@@ -19,8 +19,28 @@ export type BusinessPrimaryColorTokens = {
primaryLight: string primaryLight: string
primaryGlow: string primaryGlow: string
primaryRgb: 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< export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId, BusinessPrimaryColorId,
BusinessPrimaryColorTokens BusinessPrimaryColorTokens
@@ -32,6 +52,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#fee2e2', primaryLight: '#fee2e2',
primaryGlow: '#ef4444', primaryGlow: '#ef4444',
primaryRgb: '239 68 68', primaryRgb: '239 68 68',
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
}, },
yellow: { yellow: {
label: 'Yellow', label: 'Yellow',
@@ -40,6 +62,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#fef9c3', primaryLight: '#fef9c3',
primaryGlow: '#eab308', primaryGlow: '#eab308',
primaryRgb: '234 179 8', primaryRgb: '234 179 8',
primaryDarkRgb: '202 138 4',
...PURPLE_ACCENT,
}, },
black: { black: {
label: 'Black', label: 'Black',
@@ -48,6 +72,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#e2e8f0', primaryLight: '#e2e8f0',
primaryGlow: '#334155', primaryGlow: '#334155',
primaryRgb: '30 41 59', primaryRgb: '30 41 59',
primaryDarkRgb: '15 23 42',
...CYAN_ACCENT,
}, },
cyan: { cyan: {
label: 'Cyan', label: 'Cyan',
@@ -56,6 +82,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#cffafe', primaryLight: '#cffafe',
primaryGlow: '#06b6d4', primaryGlow: '#06b6d4',
primaryRgb: '6 182 212', primaryRgb: '6 182 212',
primaryDarkRgb: '8 145 178',
...PURPLE_ACCENT,
}, },
purple: { purple: {
label: 'Purple', label: 'Purple',
@@ -64,6 +92,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#f3e8ff', primaryLight: '#f3e8ff',
primaryGlow: '#a855f7', primaryGlow: '#a855f7',
primaryRgb: '168 85 247', primaryRgb: '168 85 247',
primaryDarkRgb: '147 51 234',
...CYAN_ACCENT,
}, },
'light-blue': { 'light-blue': {
label: 'Light Blue', label: 'Light Blue',
@@ -72,6 +102,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#e0f2fe', primaryLight: '#e0f2fe',
primaryGlow: '#38bdf8', primaryGlow: '#38bdf8',
primaryRgb: '56 189 248', primaryRgb: '56 189 248',
primaryDarkRgb: '14 165 233',
...CYAN_ACCENT,
}, },
'dark-blue': { 'dark-blue': {
label: 'Dark Blue', label: 'Dark Blue',
@@ -80,6 +112,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#dbeafe', primaryLight: '#dbeafe',
primaryGlow: '#3b82f6', primaryGlow: '#3b82f6',
primaryRgb: '59 130 246', primaryRgb: '59 130 246',
primaryDarkRgb: '37 99 235',
...CYAN_ACCENT,
}, },
} }
@@ -93,3 +127,11 @@ export function normalizeBusinessPrimaryColorId(value: unknown): BusinessPrimary
return DEFAULT_BUSINESS_PRIMARY_COLOR_ID return DEFAULT_BUSINESS_PRIMARY_COLOR_ID
} }
export function getBusinessPrimaryColorTokens(
colorId: BusinessPrimaryColorId | undefined | null,
): BusinessPrimaryColorTokens {
return BUSINESS_PRIMARY_COLOR_PALETTE[
normalizeBusinessPrimaryColorId(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
]
}
+39 -2
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes. > **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes. > Update this document when a major feature is completed or architecture changes.
Last updated: July 26, 2026 Last updated: August 1, 2026
--- ---
@@ -496,11 +496,48 @@ SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h (option A:
--- ---
## Locale (FA / EN)
Dashboards support **Farsi + English** via shared `LocaleProvider` (`@meshkee/dashboard-ui`) and `LanguageSelect` in headers.
| Piece | Where |
|-------|--------|
| Locale state | `packages/dashboard-ui``LocaleContext` |
| Default language per business | `settings.branding.defaultLocale`: `'fa' \| 'en'` (default **`fa`**) |
| Applied on open | Business + customer `TenantBrandingProvider` once from tenant branding |
| Super-admin edit | Businesses list **Lang** column + edit modal |
| Business UI copy | `apps/business/src/i18n/messages.ts` + `useT()` |
| Customer UI copy | `apps/customer/src/i18n/` (parallel pattern) |
| Document titles | Locale-aware via `routeTitles` + `useDashboardDocumentTitle` |
| Fonts | `--font-ui: var(--font-en), var(--font-fa)` — see `.cursor/rules/ui-farsi-fonts.mdc` |
RTL: `dir="rtl"` when locale is `fa`.
---
## Business home dashboard
Home is **not** a 1:1 mirror of the sidebar. Tiles are curated (no Settings tile).
| Feature | Detail |
|---------|--------|
| Section cards | Count pill + arrow (settings/website have arrow only); hover fills primary |
| Counts | Existing list `total` with `pageSize=1` — light, not a new stats join |
| Charts (6/12 each) | Orders (+ add-to-basket) and Customers (+ active logins), last 30 days |
| Chart API | `GET .../orders/activity?days=30`, `GET .../customers/activity?days=30` |
| Chart colors | Primary series = theme primary; accent = purple for red theme, cyan for blue themes (`--chart-accent` from `businessPrimaryColors`) |
| Page aura | Slow-moving radial blobs on `body::before` (tokens + business `index.css`) |
Products overview page uses the same i18n + theme-aware `ProductActivityChart` (added vs updated, 12 months).
---
## Suggested next work ## Suggested next work
- Point `meshkee.com/invoices/*` at the public invoice viewer (proxy or dedicated host) - Point `meshkee.com/invoices/*` at the public invoice viewer (proxy or dedicated host)
- Business-dashboard invoice templates + issue flow (`owner_scope=business`) - Business-dashboard invoice templates + issue flow (`owner_scope=business`)
- Migrate business and super-admin to `@meshkee/dashboard-core` / `@meshkee/dashboard-ui` - Migrate business and super-admin fully onto `@meshkee/dashboard-core` / `@meshkee/dashboard-ui` (LocaleProvider already shared)
- Finish FA/EN coverage on remaining business form pages (many labels still English)
- Connect product comments to backend - Connect product comments to backend
- Wire order status transitions to store `orderProcessSteps` - Wire order status transitions to store `orderProcessSteps`
- Enforce `onlineSellEnabled` on public website checkout - Enforce `onlineSellEnabled` on public website checkout

Some files were not shown because too many files have changed in this diff Show More