mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
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:
co-authored by
Cursor
parent
f5b2193ba1
commit
66004a0fba
@@ -21,6 +21,10 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .separator {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { translateBreadcrumbLabel } from '../i18n/messages'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './Breadcrumbs.module.css'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
@@ -12,11 +15,15 @@ interface BreadcrumbsProps {
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
const { locale } = useLocale()
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<nav className={styles.breadcrumbs} aria-label="Breadcrumb">
|
||||
<nav className={styles.breadcrumbs} aria-label={t('common.breadcrumb')}>
|
||||
<ol className={styles.list}>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
const label = translateBreadcrumbLabel(locale, item.label)
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className={styles.item}>
|
||||
{index > 0 && (
|
||||
@@ -24,10 +31,10 @@ export function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||
)}
|
||||
{item.href && !isLast ? (
|
||||
<Link to={item.href} className={styles.link}>
|
||||
{item.label}
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? styles.current : styles.text}>{item.label}</span>
|
||||
<span className={isLast ? styles.current : styles.text}>{label}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -46,16 +46,24 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .chevron {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .chevronOpen {
|
||||
transform: scaleX(-1) rotate(90deg);
|
||||
}
|
||||
|
||||
.names {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -75,7 +83,7 @@
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.legendDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tonePrimary {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.toneAccent {
|
||||
background: var(--chart-accent, #a855f7);
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 40px 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.chartWrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 100%;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.group {
|
||||
flex: 1;
|
||||
min-width: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 180px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 7px;
|
||||
border-radius: 4px 4px 2px 2px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.barPrimary {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--primary) 55%, #ffffff) 0%,
|
||||
var(--primary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.barAccent {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--chart-accent, #a855f7) 55%, #ffffff) 0%,
|
||||
var(--chart-accent, #a855f7) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-en), var(--font-ui), sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import type {
|
||||
DailyActivityPoint,
|
||||
DualDailyActivityResponse,
|
||||
} from '../services/dailyActivityService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from './DailyActivityChart.module.css'
|
||||
|
||||
const CHART_HEIGHT = 180
|
||||
const BAR_GAP = 2
|
||||
|
||||
interface DailyActivityChartProps {
|
||||
titleKey: BusinessMessageKey
|
||||
subtitleKey: BusinessMessageKey
|
||||
primaryLegendKey: BusinessMessageKey
|
||||
secondaryLegendKey: BusinessMessageKey
|
||||
loadingKey: BusinessMessageKey
|
||||
errorKey: BusinessMessageKey
|
||||
primaryBarTitleKey: BusinessMessageKey
|
||||
secondaryBarTitleKey: BusinessMessageKey
|
||||
load: (signal: AbortSignal) => Promise<DualDailyActivityResponse>
|
||||
}
|
||||
|
||||
function formatDayLabel(dateKey: string, locale: string): string {
|
||||
const [year, month, day] = dateKey.split('-').map(Number)
|
||||
const date = new Date(year, month - 1, day)
|
||||
return date.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
day: 'numeric',
|
||||
numberingSystem: 'latn',
|
||||
})
|
||||
}
|
||||
|
||||
export function DailyActivityChart({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
primaryLegendKey,
|
||||
secondaryLegendKey,
|
||||
loadingKey,
|
||||
errorKey,
|
||||
primaryBarTitleKey,
|
||||
secondaryBarTitleKey,
|
||||
load,
|
||||
}: DailyActivityChartProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [primaryItems, setPrimaryItems] = useState<DailyActivityPoint[]>([])
|
||||
const [secondaryItems, setSecondaryItems] = useState<DailyActivityPoint[]>([])
|
||||
const [primaryTotal, setPrimaryTotal] = useState(0)
|
||||
const [secondaryTotal, setSecondaryTotal] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function run() {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await load(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setPrimaryItems(data.primary.items)
|
||||
setSecondaryItems(data.secondary.items)
|
||||
setPrimaryTotal(data.primary.total)
|
||||
setSecondaryTotal(data.secondary.total)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t(errorKey))
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
return () => controller.abort()
|
||||
}, [load, t, errorKey])
|
||||
|
||||
const maxValue = useMemo(() => {
|
||||
const peak = Math.max(
|
||||
...primaryItems.map((item) => item.count),
|
||||
...secondaryItems.map((item) => item.count),
|
||||
0,
|
||||
)
|
||||
return peak > 0 ? peak : 1
|
||||
}, [primaryItems, secondaryItems])
|
||||
|
||||
return (
|
||||
<section className={styles.card} aria-label={t(titleKey)}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h3 className={styles.title}>{t(titleKey)}</h3>
|
||||
<p className={styles.subtitle}>{t(subtitleKey)}</p>
|
||||
</div>
|
||||
<div className={styles.legend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.legendDot} ${styles.tonePrimary}`} />
|
||||
{t(primaryLegendKey, { count: primaryTotal })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.legendDot} ${styles.toneAccent}`} />
|
||||
{t(secondaryLegendKey, { count: secondaryTotal })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>{t(loadingKey)}</p>
|
||||
) : error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<div className={styles.chartWrap}>
|
||||
<div
|
||||
className={styles.chart}
|
||||
style={{ height: CHART_HEIGHT + 28 }}
|
||||
role="img"
|
||||
aria-label={t(titleKey)}
|
||||
>
|
||||
{primaryItems.map((item, index) => {
|
||||
const secondary = secondaryItems[index]
|
||||
const secondaryCount = secondary?.count ?? 0
|
||||
const primaryHeight = (item.count / maxValue) * CHART_HEIGHT
|
||||
const secondaryHeight = (secondaryCount / maxValue) * CHART_HEIGHT
|
||||
const label = formatDayLabel(item.date, locale)
|
||||
|
||||
return (
|
||||
<div key={item.date} className={styles.group}>
|
||||
<div className={styles.bars} style={{ gap: BAR_GAP }}>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barPrimary}`}
|
||||
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
|
||||
title={t(primaryBarTitleKey, { day: label, count: item.count })}
|
||||
/>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barAccent}`}
|
||||
style={{
|
||||
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
|
||||
}}
|
||||
title={t(secondaryBarTitleKey, {
|
||||
day: label,
|
||||
count: secondaryCount,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.label} lang="en" dir="ltr">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useDashboardDocumentTitle } from '@meshkee/dashboard-ui'
|
||||
import { useDashboardDocumentTitle, useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { BUSINESS_DASHBOARD_NAME, businessRouteTitleRules } from '../lib/routeTitles'
|
||||
import { getBusinessRouteTitleRules, translate } from '../i18n/messages'
|
||||
|
||||
export function DashboardDocumentTitle() {
|
||||
const { pathname } = useLocation()
|
||||
const { businessName } = useTenantBranding()
|
||||
const { locale } = useLocale()
|
||||
|
||||
useDashboardDocumentTitle({
|
||||
businessName,
|
||||
dashboardName: BUSINESS_DASHBOARD_NAME,
|
||||
dashboardName: translate(locale, 'app.dashboardName'),
|
||||
pathname,
|
||||
routeRules: businessRouteTitleRules,
|
||||
routeRules: getBusinessRouteTitleRules(locale),
|
||||
})
|
||||
|
||||
return null
|
||||
|
||||
@@ -60,9 +60,10 @@
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 18px;
|
||||
inset-inline-end: 4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -70,7 +71,8 @@
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
@@ -82,7 +84,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px 6px 6px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 6px 12px;
|
||||
border-radius: 50px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -96,13 +99,6 @@
|
||||
border-color: rgba(var(--primary-rgb) / 0.25);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profileInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -132,7 +128,7 @@
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
min-width: 180px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
|
||||
@@ -1,32 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Menu, Bell, MessageSquare, ChevronDown, User, Settings, KeyRound, LogOut } from 'lucide-react'
|
||||
import { PasswordResetModal } from '@meshkee/dashboard-ui'
|
||||
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { changePassword } from '../services/authService'
|
||||
import styles from './Header.module.css'
|
||||
|
||||
const profileMenuItems = [
|
||||
{ icon: User, label: 'Profile', to: '/profile' },
|
||||
{ icon: Settings, label: 'Setting', to: '/settings' },
|
||||
]
|
||||
function displayUserName(
|
||||
user: {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
firstNameEn?: string | null
|
||||
lastNameEn?: string | null
|
||||
cellNumber: string
|
||||
} | null,
|
||||
locale: 'en' | 'fa',
|
||||
fallback: string,
|
||||
) {
|
||||
if (!user) return fallback
|
||||
const localized =
|
||||
locale === 'en'
|
||||
? [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ')
|
||||
: [user.firstName, user.lastName].filter(Boolean).join(' ')
|
||||
const other =
|
||||
locale === 'en'
|
||||
? [user.firstName, user.lastName].filter(Boolean).join(' ')
|
||||
: [user.firstNameEn, user.lastNameEn].filter(Boolean).join(' ')
|
||||
return localized || other || user.cellNumber || fallback
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const { locale } = useLocale()
|
||||
const t = useT()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const displayName =
|
||||
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.cellNumber || 'User'
|
||||
const displayName = displayUserName(user, locale, t('app.userFallback'))
|
||||
const roleLabel =
|
||||
user?.roleLabel ??
|
||||
(user?.isSuperAdmin || user?.roles.includes('super_admin')
|
||||
? 'Super Admin'
|
||||
? t('role.superAdmin')
|
||||
: user?.businesses[0]?.isOwner
|
||||
? 'Business Owner'
|
||||
: user?.businesses[0]?.teamRole ?? 'Staff')
|
||||
? t('role.owner')
|
||||
: user?.businesses[0]?.teamRole ?? t('role.staff'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
@@ -64,21 +84,23 @@ export function Header() {
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.left}>
|
||||
<button className={styles.menuBtn} aria-label="Toggle menu">
|
||||
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<h1 className={styles.title}>Admin Dashboard</h1>
|
||||
<h1 className={styles.title}>{t('header.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.right}>
|
||||
<button className={styles.iconBtn} aria-label="Messages">
|
||||
<LanguageSelect />
|
||||
|
||||
<button className={styles.iconBtn} aria-label={t('header.messages')}>
|
||||
<MessageSquare size={20} />
|
||||
<span className={styles.badge}>5</span>
|
||||
<span className={styles.badge}>0</span>
|
||||
</button>
|
||||
|
||||
<button className={styles.iconBtn} aria-label="Notifications">
|
||||
<button className={styles.iconBtn} aria-label={t('header.notifications')}>
|
||||
<Bell size={20} />
|
||||
<span className={styles.badge}>3</span>
|
||||
<span className={styles.badge}>0</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.profileWrap} ref={menuRef}>
|
||||
@@ -89,11 +111,6 @@ export function Header() {
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<img
|
||||
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(displayName)}`}
|
||||
alt={displayName}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.profileInfo}>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
<span className={styles.role}>{roleLabel}</span>
|
||||
@@ -106,18 +123,24 @@ export function Header() {
|
||||
|
||||
{menuOpen && (
|
||||
<div className={styles.dropdown} role="menu">
|
||||
{profileMenuItems.map(({ icon: Icon, label, to }) => (
|
||||
<Link
|
||||
key={label}
|
||||
to={to}
|
||||
className={styles.dropdownItem}
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
to="/profile"
|
||||
className={styles.dropdownItem}
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<User size={16} />
|
||||
<span>{t('header.profile')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={styles.dropdownItem}
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>{t('header.setting')}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownItem}
|
||||
@@ -125,7 +148,7 @@ export function Header() {
|
||||
onClick={openPasswordModal}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
<span>Change password</span>
|
||||
<span>{t('header.changePassword')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -134,7 +157,7 @@ export function Header() {
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Logout</span>
|
||||
<span>{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -146,6 +169,7 @@ export function Header() {
|
||||
open={passwordModalOpen}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
onChangePassword={changePassword}
|
||||
title={t('header.changePassword')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: var(--sidebar-width);
|
||||
margin-inline-start: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -12,6 +12,6 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main {
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
.titleFa {
|
||||
margin: 0;
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import { ChevronUp, Pencil, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import type { Portfolio } from '../types/portfolio'
|
||||
import { formatPortfolioCardDate } from '../services/portfolioService'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
@@ -10,6 +10,9 @@ import controlStyles from './ProductCard.module.css'
|
||||
interface PortfolioCardProps {
|
||||
portfolio: Portfolio
|
||||
commentCount: number
|
||||
canMoveUp: boolean
|
||||
isMovingUp?: boolean
|
||||
onMoveUp: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onComments: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
@@ -18,6 +21,9 @@ interface PortfolioCardProps {
|
||||
export function PortfolioCard({
|
||||
portfolio,
|
||||
commentCount,
|
||||
canMoveUp,
|
||||
isMovingUp = false,
|
||||
onMoveUp,
|
||||
onEdit,
|
||||
onComments,
|
||||
onRemove,
|
||||
@@ -87,6 +93,16 @@ export function PortfolioCard({
|
||||
</Link>
|
||||
|
||||
<div className={controlStyles.controls}>
|
||||
<Tooltip label="Move up">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(portfolio.id)}
|
||||
disabled={!canMoveUp || isMovingUp}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit portfolio">
|
||||
<button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit">
|
||||
<Pencil size={16} />
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
.legendUpdated {
|
||||
background: var(--primary-dark);
|
||||
background: var(--chart-accent, #a855f7);
|
||||
}
|
||||
|
||||
.status,
|
||||
@@ -115,8 +115,8 @@
|
||||
.barUpdated {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--primary-dark) 55%, #ffffff) 0%,
|
||||
var(--primary-dark) 100%
|
||||
color-mix(in srgb, var(--chart-accent, #a855f7) 55%, #ffffff) 0%,
|
||||
var(--chart-accent, #a855f7) 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllProducts } from '../services/productService'
|
||||
import { aggregateProductActivity, type ProductMonthActivity } from '../utils/productActivity'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './ProductActivityChart.module.css'
|
||||
|
||||
const CHART_HEIGHT = 200
|
||||
const BAR_GAP = 6
|
||||
|
||||
export function ProductActivityChart() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [data, setData] = useState<ProductMonthActivity[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -16,7 +20,7 @@ export function ProductActivityChart() {
|
||||
const controller = new AbortController()
|
||||
void loadActivity(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
}, [locale])
|
||||
|
||||
async function loadActivity(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
@@ -24,13 +28,13 @@ export function ProductActivityChart() {
|
||||
|
||||
try {
|
||||
const products = await listAllProducts(signal)
|
||||
setData(aggregateProductActivity(products))
|
||||
setData(aggregateProductActivity(products, locale))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load product activity.')
|
||||
setError(t('products.activity.error'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -51,26 +55,26 @@ export function ProductActivityChart() {
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={styles.card} aria-label="Product activity chart">
|
||||
<section className={styles.card} aria-label={t('products.activity.title')}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h3 className={styles.title}>Product activity</h3>
|
||||
<p className={styles.subtitle}>Products added or updated in the last 12 months</p>
|
||||
<h3 className={styles.title}>{t('products.activity.title')}</h3>
|
||||
<p className={styles.subtitle}>{t('products.activity.subtitle')}</p>
|
||||
</div>
|
||||
<div className={styles.legend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.legendDot} ${styles.legendAdded}`} />
|
||||
Added ({totals.added})
|
||||
{t('products.activity.added', { count: totals.added })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={`${styles.legendDot} ${styles.legendUpdated}`} />
|
||||
Updated ({totals.updated})
|
||||
{t('products.activity.updated', { count: totals.updated })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>Loading chart...</p>
|
||||
<p className={styles.status}>{t('products.activity.loading')}</p>
|
||||
) : error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
@@ -81,7 +85,7 @@ export function ProductActivityChart() {
|
||||
className={styles.chart}
|
||||
style={{ height: CHART_HEIGHT + 32 }}
|
||||
role="img"
|
||||
aria-label="Bar chart of products added and updated per month"
|
||||
aria-label={t('products.activity.chartAria')}
|
||||
>
|
||||
{data.map((item) => {
|
||||
const addedHeight = (item.added / maxValue) * CHART_HEIGHT
|
||||
@@ -93,12 +97,18 @@ export function ProductActivityChart() {
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barAdded}`}
|
||||
style={{ height: Math.max(addedHeight, item.added > 0 ? 4 : 0) }}
|
||||
title={`${item.label}: ${item.added} added`}
|
||||
title={t('products.activity.barAdded', {
|
||||
month: item.label,
|
||||
count: item.added,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={`${styles.bar} ${styles.barUpdated}`}
|
||||
style={{ height: Math.max(updatedHeight, item.updated > 0 ? 4 : 0) }}
|
||||
title={`${item.label}: ${item.updated} updated`}
|
||||
title={t('products.activity.barUpdated', {
|
||||
month: item.label,
|
||||
count: item.updated,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
@@ -160,6 +160,12 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls button.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
.optionFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 500;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@@ -56,28 +56,78 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
.countMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
height: 36px;
|
||||
max-width: calc(100% - 48px);
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
transition:
|
||||
background 0.35s ease,
|
||||
color 0.35s ease;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
font-family: var(--font-en), var(--font-ui), sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card:hover .countMeta {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card:hover .countLabel {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.arrowBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
transition:
|
||||
background 0.35s ease,
|
||||
color 0.35s ease;
|
||||
}
|
||||
|
||||
.arrowIcon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card:hover .arrowBtn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .arrowBtn {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,26 @@ interface SectionCardProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
linkText: string
|
||||
linkText?: string
|
||||
href: string
|
||||
/** Entity total shown beside the arrow; omit for sections without a count (e.g. settings). */
|
||||
count?: number | null
|
||||
countLabel?: string
|
||||
}
|
||||
|
||||
export function SectionCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
linkText,
|
||||
href,
|
||||
count,
|
||||
countLabel,
|
||||
}: SectionCardProps) {
|
||||
const showCount = typeof count === 'number' && Number.isFinite(count)
|
||||
const formattedCount = showCount
|
||||
? new Intl.NumberFormat('en-US').format(count)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Link to={href} className={styles.card}>
|
||||
<div className={styles.iconWrap}>
|
||||
@@ -27,9 +36,18 @@ export function SectionCard({
|
||||
<p className={styles.description}>{description}</p>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.link}>{linkText}</span>
|
||||
{formattedCount !== null && countLabel ? (
|
||||
<div className={styles.countMeta}>
|
||||
<span className={styles.countValue} lang="en" dir="ltr">
|
||||
{formattedCount}
|
||||
</span>
|
||||
<span className={styles.countLabel}>{countLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className={styles.arrowBtn} aria-hidden="true">
|
||||
<ArrowRight size={18} />
|
||||
<ArrowRight size={18} className={styles.arrowIcon} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -10,7 +10,7 @@
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
border-inline-end: 1px solid var(--glass-border);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -45,26 +45,26 @@
|
||||
}
|
||||
|
||||
.brandDomain {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
padding-inline-end: 2px;
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
@@ -83,7 +83,7 @@
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
@@ -118,9 +118,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 2px 0 4px 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid rgba(148, 163, 184, 0.2);
|
||||
margin-block: 2px 4px;
|
||||
margin-inline-start: 12px;
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 2px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.subNavItem {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Home,
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import {
|
||||
BUSINESS_PROFILE_UPDATED_EVENT,
|
||||
getActiveBusinessDomain,
|
||||
@@ -26,111 +29,29 @@ import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
interface NavChild {
|
||||
label: string
|
||||
labelKey: BusinessMessageKey
|
||||
to: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
type: 'group'
|
||||
id: string
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
labelKey: BusinessMessageKey
|
||||
basePath: string
|
||||
children: NavChild[]
|
||||
}
|
||||
|
||||
interface NavLinkItem {
|
||||
type: 'link'
|
||||
id: string
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
labelKey: BusinessMessageKey
|
||||
to: string
|
||||
}
|
||||
|
||||
type NavItem = NavLinkItem | NavGroup
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ type: 'link', icon: Home, label: 'Home', to: '/' },
|
||||
{ type: 'link', icon: Building2, label: 'Business Profile', to: '/business-profile' },
|
||||
{
|
||||
type: 'group',
|
||||
icon: ShoppingBag,
|
||||
label: 'Products',
|
||||
basePath: '/products',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/products' },
|
||||
{ label: 'My Products', to: '/products/list' },
|
||||
{ label: 'Add New Product', to: '/products/new' },
|
||||
{ label: 'Categories', to: '/products/categories' },
|
||||
{ label: 'Brands', to: '/products/brands' },
|
||||
{ label: 'Settings', to: '/products/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
icon: Store,
|
||||
label: 'Store',
|
||||
basePath: '/store',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/store' },
|
||||
{ label: 'My Store Items', to: '/store/items' },
|
||||
{ label: 'My Orders', to: '/store/orders' },
|
||||
{ label: 'Shipping Fees', to: '/store/shipping' },
|
||||
{ label: 'Shopping Cards', to: '/store/cards' },
|
||||
{ label: 'Settings', to: '/store/settings' },
|
||||
],
|
||||
},
|
||||
{ type: 'link', icon: Users, label: 'Customers', to: '/customers' },
|
||||
{ type: 'link', icon: Settings, label: 'Settings', to: '/settings' },
|
||||
{
|
||||
type: 'group',
|
||||
icon: FileText,
|
||||
label: 'Blog',
|
||||
basePath: '/blog',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/blog' },
|
||||
{ label: 'My Blogs', to: '/blog/list' },
|
||||
{ label: 'Add New Blog', to: '/blog/new' },
|
||||
{ label: 'Categories', to: '/blog/categories' },
|
||||
{ label: 'Settings', to: '/blog/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
icon: Briefcase,
|
||||
label: 'Portfolios',
|
||||
basePath: '/portfolios',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/portfolios' },
|
||||
{ label: 'My Portfolios', to: '/portfolios/list' },
|
||||
{ label: 'Add New Portfolio', to: '/portfolios/new' },
|
||||
{ label: 'Categories', to: '/portfolios/categories' },
|
||||
{ label: 'Settings', to: '/portfolios/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
icon: Globe,
|
||||
label: 'Website',
|
||||
basePath: '/website',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/website' },
|
||||
{ label: 'Sliders', to: '/website/sliders' },
|
||||
{ label: 'Special Categories', to: '/website/special-categories' },
|
||||
{ label: 'Special Brands', to: '/website/special-brands' },
|
||||
{ label: 'Special Items', to: '/website/special-items' },
|
||||
{ label: 'Contact Us Form', to: '/website/contact' },
|
||||
{ label: 'Subscriptions', to: '/website/subscriptions' },
|
||||
{ label: 'FAQ', to: '/website/faq' },
|
||||
{ label: 'Badges', to: '/website/badges' },
|
||||
{ label: 'E-Payment', to: '/website/e-payment' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const footerItems = [
|
||||
{ icon: HelpCircle, label: 'Help Center' },
|
||||
{ icon: LogOut, label: 'Logout' },
|
||||
]
|
||||
|
||||
function isGroupActive(basePath: string, pathname: string) {
|
||||
return pathname === basePath || pathname.startsWith(`${basePath}/`)
|
||||
}
|
||||
@@ -139,12 +60,115 @@ export function Sidebar() {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const { locale } = useLocale()
|
||||
const t = useT()
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||
const [brandLogoUrl, setBrandLogoUrl] = useState<string | null>(null)
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [nameEn, setNameEn] = useState('')
|
||||
const [nameFa, setNameFa] = useState('')
|
||||
|
||||
const businessDomain = getActiveBusinessDomain()
|
||||
const fallbackBusinessName = user?.businesses[0]?.name ?? 'Business'
|
||||
const fallbackBusinessName = user?.businesses[0]?.name ?? t('app.storeFallback')
|
||||
|
||||
const brandName = useMemo(() => {
|
||||
if (locale === 'fa') {
|
||||
return nameFa.trim() || nameEn.trim() || fallbackBusinessName
|
||||
}
|
||||
return nameEn.trim() || nameFa.trim() || fallbackBusinessName
|
||||
}, [locale, nameEn, nameFa, fallbackBusinessName])
|
||||
|
||||
const navItems = useMemo<NavItem[]>(
|
||||
() => [
|
||||
{ type: 'link', id: 'home', icon: Home, labelKey: 'nav.home', to: '/' },
|
||||
{
|
||||
type: 'link',
|
||||
id: 'business-profile',
|
||||
icon: Building2,
|
||||
labelKey: 'nav.businessProfile',
|
||||
to: '/business-profile',
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'products',
|
||||
icon: ShoppingBag,
|
||||
labelKey: 'nav.products',
|
||||
basePath: '/products',
|
||||
children: [
|
||||
{ labelKey: 'nav.products.overview', to: '/products' },
|
||||
{ labelKey: 'nav.products.list', to: '/products/list' },
|
||||
{ labelKey: 'nav.products.new', to: '/products/new' },
|
||||
{ labelKey: 'nav.products.categories', to: '/products/categories' },
|
||||
{ labelKey: 'nav.products.brands', to: '/products/brands' },
|
||||
{ labelKey: 'nav.products.settings', to: '/products/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'store',
|
||||
icon: Store,
|
||||
labelKey: 'nav.store',
|
||||
basePath: '/store',
|
||||
children: [
|
||||
{ labelKey: 'nav.store.overview', to: '/store' },
|
||||
{ labelKey: 'nav.store.items', to: '/store/items' },
|
||||
{ labelKey: 'nav.store.orders', to: '/store/orders' },
|
||||
{ labelKey: 'nav.store.shipping', to: '/store/shipping' },
|
||||
{ labelKey: 'nav.store.cards', to: '/store/cards' },
|
||||
{ labelKey: 'nav.store.settings', to: '/store/settings' },
|
||||
],
|
||||
},
|
||||
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
||||
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
|
||||
{
|
||||
type: 'group',
|
||||
id: 'blog',
|
||||
icon: FileText,
|
||||
labelKey: 'nav.blog',
|
||||
basePath: '/blog',
|
||||
children: [
|
||||
{ labelKey: 'nav.blog.overview', to: '/blog' },
|
||||
{ labelKey: 'nav.blog.list', to: '/blog/list' },
|
||||
{ labelKey: 'nav.blog.new', to: '/blog/new' },
|
||||
{ labelKey: 'nav.blog.categories', to: '/blog/categories' },
|
||||
{ labelKey: 'nav.blog.settings', to: '/blog/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'portfolios',
|
||||
icon: Briefcase,
|
||||
labelKey: 'nav.portfolios',
|
||||
basePath: '/portfolios',
|
||||
children: [
|
||||
{ labelKey: 'nav.portfolios.overview', to: '/portfolios' },
|
||||
{ labelKey: 'nav.portfolios.list', to: '/portfolios/list' },
|
||||
{ labelKey: 'nav.portfolios.new', to: '/portfolios/new' },
|
||||
{ labelKey: 'nav.portfolios.categories', to: '/portfolios/categories' },
|
||||
{ labelKey: 'nav.portfolios.settings', to: '/portfolios/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'website',
|
||||
icon: Globe,
|
||||
labelKey: 'nav.website',
|
||||
basePath: '/website',
|
||||
children: [
|
||||
{ labelKey: 'nav.website.overview', to: '/website' },
|
||||
{ labelKey: 'nav.website.sliders', to: '/website/sliders' },
|
||||
{ labelKey: 'nav.website.specialCategories', to: '/website/special-categories' },
|
||||
{ labelKey: 'nav.website.specialBrands', to: '/website/special-brands' },
|
||||
{ labelKey: 'nav.website.specialItems', to: '/website/special-items' },
|
||||
{ labelKey: 'nav.website.contact', to: '/website/contact' },
|
||||
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
|
||||
{ labelKey: 'nav.website.faq', to: '/website/faq' },
|
||||
{ labelKey: 'nav.website.badges', to: '/website/badges' },
|
||||
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -153,11 +177,13 @@ export function Sidebar() {
|
||||
try {
|
||||
const data = await getBusinessProfile(controller.signal)
|
||||
setBrandLogoUrl(data.profile.logoUrl)
|
||||
setBrandName(data.profile.nameEn.trim() || data.profile.nameFa.trim() || fallbackBusinessName)
|
||||
setNameEn(data.profile.nameEn.trim())
|
||||
setNameFa(data.profile.nameFa.trim())
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setBrandLogoUrl(null)
|
||||
setBrandName(fallbackBusinessName)
|
||||
setNameEn('')
|
||||
setNameFa('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,18 +198,18 @@ export function Sidebar() {
|
||||
controller.abort()
|
||||
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
|
||||
}
|
||||
}, [fallbackBusinessName])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
navItems.forEach((item) => {
|
||||
if (item.type === 'group' && isGroupActive(item.basePath, pathname)) {
|
||||
setOpenGroups((prev) => ({ ...prev, [item.label]: true }))
|
||||
setOpenGroups((prev) => ({ ...prev, [item.id]: true }))
|
||||
}
|
||||
})
|
||||
}, [pathname])
|
||||
}, [pathname, navItems])
|
||||
|
||||
function toggleGroup(label: string) {
|
||||
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }))
|
||||
function toggleGroup(id: string) {
|
||||
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -191,12 +217,12 @@ export function Sidebar() {
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={brandLogoUrl ?? meshkeeLogo}
|
||||
alt={brandName || 'Business logo'}
|
||||
alt={brandName || t('app.storeFallback')}
|
||||
className={`${styles.brandLogo} ${brandLogoUrl ? styles.brandLogoUploaded : ''}`}
|
||||
/>
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||
<span className={styles.brandName}>{brandName || fallbackBusinessName}</span>
|
||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,7 +231,7 @@ export function Sidebar() {
|
||||
if (item.type === 'link') {
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
key={item.id}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
@@ -213,24 +239,24 @@ export function Sidebar() {
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
const isOpen = openGroups[item.label] ?? false
|
||||
const isOpen = openGroups[item.id] ?? false
|
||||
const groupActive = isGroupActive(item.basePath, pathname)
|
||||
|
||||
return (
|
||||
<div key={item.label} className={styles.navGroup}>
|
||||
<div key={item.id} className={styles.navGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.navItem} ${styles.navGroupBtn} ${groupActive ? styles.active : ''}`}
|
||||
onClick={() => toggleGroup(item.label)}
|
||||
onClick={() => toggleGroup(item.id)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className={styles.navGroupLabel}>{item.label}</span>
|
||||
<span className={styles.navGroupLabel}>{t(item.labelKey)}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
|
||||
@@ -248,7 +274,7 @@ export function Sidebar() {
|
||||
`${styles.subNavItem} ${isActive ? styles.subNavActive : ''}`
|
||||
}
|
||||
>
|
||||
{child.label}
|
||||
{t(child.labelKey)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
@@ -259,22 +285,21 @@ export function Sidebar() {
|
||||
</nav>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{footerItems.map(({ icon: Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={styles.navItem}
|
||||
onClick={() => {
|
||||
if (label === 'Logout') {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className={styles.navItem}>
|
||||
<HelpCircle size={20} />
|
||||
<span>{t('nav.help')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navItem}
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
.nameFa {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
|
||||
Reference in New Issue
Block a user