mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
key={label}
|
to="/profile"
|
||||||
to={to}
|
|
||||||
className={styles.dropdownItem}
|
className={styles.dropdownItem}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
<User size={16} />
|
||||||
<span>{label}</span>
|
<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>
|
</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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
<HelpCircle size={20} />
|
||||||
|
<span>{t('nav.help')}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
key={label}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.navItem}
|
className={styles.navItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (label === 'Logout') {
|
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
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 }),
|
||||||
|
|||||||
@@ -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')] },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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's what'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (err.message === BUSINESS_ACCESS_MESSAGE) {
|
||||||
|
setError(t('login.error.access'))
|
||||||
|
} else {
|
||||||
setError(err.message)
|
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'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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,19 +28,26 @@ 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) {
|
export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const t = useT()
|
||||||
|
const { date, time } = formatDateTime(order.createdAt, locale)
|
||||||
|
const itemQty = totalItemQuantity(order)
|
||||||
|
const processStepId = order.processStepId ?? processSteps[0]?.id ?? 'processing'
|
||||||
|
|
||||||
|
function sourceLabel(source: OrderSource) {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return 'Operator'
|
return t('orders.source.operator')
|
||||||
case 'app':
|
case 'app':
|
||||||
return 'Application'
|
return t('orders.source.app')
|
||||||
case 'website':
|
case 'website':
|
||||||
default:
|
default:
|
||||||
return 'Website'
|
return t('orders.source.website')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function sourceClass(source: OrderSource) {
|
function sourceClass(source: OrderSource) {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return styles.sourceOperator
|
return styles.sourceOperator
|
||||||
@@ -47,12 +57,7 @@ function sourceClass(source: OrderSource) {
|
|||||||
default:
|
default:
|
||||||
return styles.sourceWebsite
|
return styles.sourceWebsite
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
|
|
||||||
const { date, time } = formatDateTime(order.createdAt)
|
|
||||||
const itemQty = totalItemQuantity(order)
|
|
||||||
const processStepId = order.processStepId ?? processSteps[0]?.id ?? 'processing'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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')] },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,233 +1,117 @@
|
|||||||
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()])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemove(index: number) {
|
|
||||||
const target = addresses[index]
|
|
||||||
if (!target) return
|
|
||||||
|
|
||||||
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) => {
|
setAddresses((prev) => {
|
||||||
const next = prev.filter((_, i) => i !== index)
|
const index = prev.findIndex((item) => item.id === saved.id)
|
||||||
return next.length > 0 ? next : [createEmptyAddressItem()]
|
if (index >= 0) {
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = saved
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return [saved, ...prev]
|
||||||
})
|
})
|
||||||
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) {
|
async function handleRemove(item: UserAddress) {
|
||||||
e.preventDefault()
|
setRemovingId(item.id)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
const payload = addresses.filter(isCompleteAddress)
|
|
||||||
if (payload.length === 0) {
|
|
||||||
setError('Add at least one complete address.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const saved: AddressDraft[] = []
|
await removeAddress(item.id)
|
||||||
|
setAddresses((prev) => prev.filter((row) => row.id !== item.id))
|
||||||
for (const item of payload) {
|
showToast(t('addresses.toast.removed'), 'success')
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAddresses(saved.length > 0 ? saved : [createEmptyAddressItem()])
|
|
||||||
showToast('Addresses saved.', '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}
|
||||||
@@ -235,25 +119,83 @@ export function AddressesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<AddressListEditor
|
<h3 className={styles.sectionTitle}>{t('addresses.editorTitle')}</h3>
|
||||||
addresses={addresses}
|
|
||||||
provinces={provinces}
|
{loading && <p className={styles.status}>{t('addresses.loading')}</p>}
|
||||||
citiesByProvince={citiesByProvince}
|
|
||||||
onAddressChange={updateAddressDraft}
|
{!loading && addresses.length === 0 && (
|
||||||
onProvinceChange={handleProvinceChange}
|
<div className={styles.empty}>
|
||||||
onAdd={addAddressRow}
|
<MapPin size={28} />
|
||||||
onRemove={(index) => void handleRemove(index)}
|
<p>{t('addresses.empty')}</p>
|
||||||
disabled={saving}
|
</div>
|
||||||
loading={loading}
|
)}
|
||||||
/>
|
|
||||||
|
{!loading && addresses.length > 0 && (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{addresses.map((item) => (
|
||||||
|
<li key={item.id} className={styles.row}>
|
||||||
|
<div className={styles.rowLine} title={formatRowLine(item)}>
|
||||||
|
{item.label && <span className={styles.rowLabel}>{item.label}</span>}
|
||||||
|
<span className={styles.rowText}>
|
||||||
|
{[
|
||||||
|
item.province,
|
||||||
|
item.city,
|
||||||
|
item.address,
|
||||||
|
item.postalCode,
|
||||||
|
item.landline,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.rowActions}>
|
||||||
|
<Tooltip label={t('addresses.edit')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.editBtn}
|
||||||
|
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>
|
</section>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<button
|
||||||
<button type="submit" className={styles.saveBtn} disabled={saving || loading || !hasAddresses}>
|
type="button"
|
||||||
{saving ? 'Saving...' : 'Save addresses'}
|
className={styles.addFab}
|
||||||
|
onClick={openCreateModal}
|
||||||
|
aria-label={t('addresses.add')}
|
||||||
|
>
|
||||||
|
<Plus size={26} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
<AddressFormModal
|
||||||
|
open={modalOpen}
|
||||||
|
address={editingAddress}
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditingAddress(null)
|
||||||
|
}}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const t = useT()
|
||||||
|
const firstName =
|
||||||
|
(locale === 'en' ? user?.firstNameEn : user?.firstName) ||
|
||||||
|
user?.firstName ||
|
||||||
|
user?.firstNameEn ||
|
||||||
|
t('home.welcomeFallback')
|
||||||
|
|
||||||
|
const sections = [
|
||||||
{
|
{
|
||||||
icon: User,
|
icon: User,
|
||||||
title: 'My Profile',
|
title: t('home.card.profile.title'),
|
||||||
description: 'View and update your personal information and contact details.',
|
description: t('home.card.profile.desc'),
|
||||||
linkText: 'View profile',
|
linkText: t('home.card.profile.link'),
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
title: 'My Addresses',
|
title: t('home.card.addresses.title'),
|
||||||
description: 'Manage your shipping addresses for checkout and deliveries.',
|
description: t('home.card.addresses.desc'),
|
||||||
linkText: 'View addresses',
|
linkText: t('home.card.addresses.link'),
|
||||||
href: '/addresses',
|
href: '/addresses',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ShoppingBag,
|
icon: ShoppingBag,
|
||||||
title: 'My Orders',
|
title: t('home.card.orders.title'),
|
||||||
description: 'Track your orders, view order history and order details.',
|
description: t('home.card.orders.desc'),
|
||||||
linkText: 'View orders',
|
linkText: t('home.card.orders.link'),
|
||||||
href: '/orders',
|
href: '/orders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
title: 'My Favorites',
|
title: t('home.card.favorites.title'),
|
||||||
description: 'Browse and manage your saved favorite products.',
|
description: t('home.card.favorites.desc'),
|
||||||
linkText: 'View favorites',
|
linkText: t('home.card.favorites.link'),
|
||||||
href: '/favorites',
|
href: '/favorites',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function getFormattedDate() {
|
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||||
return new Intl.DateTimeFormat('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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'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 {
|
||||||
|
|||||||
@@ -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 & 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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
Reference in New Issue
Block a user