mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add business modules column and selectable home charts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
a48a24e6f2
commit
0a2358b0c2
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { DailyActivityChart } from './DailyActivityChart'
|
||||||
|
import { ProductActivityChart } from './ProductActivityChart'
|
||||||
|
import {
|
||||||
|
getCustomersDailyActivity,
|
||||||
|
getOrdersDailyActivity,
|
||||||
|
} from '../services/dailyActivityService'
|
||||||
|
import type { HomeChartId } from '../utils/businessModules'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import styles from './DailyActivityChart.module.css'
|
||||||
|
|
||||||
|
interface HomeChartSlotProps {
|
||||||
|
chartId: HomeChartId
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeChartPlaceholder({ chartId }: { chartId: HomeChartId }) {
|
||||||
|
const t = useT()
|
||||||
|
const titleKey =
|
||||||
|
chartId === 'blog_views_30d'
|
||||||
|
? 'home.chart.blogViews.title'
|
||||||
|
: 'home.chart.placeholder.title'
|
||||||
|
const subtitleKey =
|
||||||
|
chartId === 'blog_views_30d'
|
||||||
|
? 'home.chart.blogViews.subtitle'
|
||||||
|
: 'home.chart.placeholder.subtitle'
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className={styles.status}>{t('home.chart.placeholder.comingSoon')}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeChartSlot({ chartId }: HomeChartSlotProps) {
|
||||||
|
const loadOrders = useCallback(
|
||||||
|
(signal: AbortSignal) => getOrdersDailyActivity(30, signal),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const loadCustomersYear = useCallback(
|
||||||
|
(signal: AbortSignal) => getCustomersDailyActivity(365, signal),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (chartId) {
|
||||||
|
case 'none':
|
||||||
|
return null
|
||||||
|
case 'orders_30d':
|
||||||
|
return (
|
||||||
|
<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={loadOrders}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'customers_joined_1y':
|
||||||
|
return (
|
||||||
|
<DailyActivityChart
|
||||||
|
titleKey="home.chart.customersJoined.title"
|
||||||
|
subtitleKey="home.chart.customersJoined.subtitle"
|
||||||
|
primaryLegendKey="home.chart.customersJoined.legend"
|
||||||
|
secondaryLegendKey="home.chart.customersJoined.activeLegend"
|
||||||
|
loadingKey="home.chart.customersJoined.loading"
|
||||||
|
errorKey="home.chart.customersJoined.error"
|
||||||
|
primaryBarTitleKey="home.chart.customersJoined.bar"
|
||||||
|
secondaryBarTitleKey="home.chart.customersJoined.activeBar"
|
||||||
|
load={loadCustomersYear}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'products_added_1y':
|
||||||
|
return <ProductActivityChart />
|
||||||
|
case 'blog_views_30d':
|
||||||
|
return <HomeChartPlaceholder chartId={chartId} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,10 @@
|
|||||||
grid-column: span 6;
|
grid-column: span 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col12 {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1536px) {
|
@media (max-width: 1536px) {
|
||||||
.gridHome {
|
.gridHome {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
} from '../lib/businessContext'
|
} from '../lib/businessContext'
|
||||||
import { isAbortError } from '../lib/api'
|
import { isAbortError } from '../lib/api'
|
||||||
import { getBusinessProfile } from '../services/businessProfileService'
|
import { getBusinessProfile } from '../services/businessProfileService'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
|
import {
|
||||||
|
hasBusinessModule,
|
||||||
|
type BusinessModuleId,
|
||||||
|
} from '../utils/businessModules'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ interface NavGroup {
|
|||||||
labelKey: BusinessMessageKey
|
labelKey: BusinessMessageKey
|
||||||
basePath: string
|
basePath: string
|
||||||
children: NavChild[]
|
children: NavChild[]
|
||||||
|
moduleId?: BusinessModuleId
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavLinkItem {
|
interface NavLinkItem {
|
||||||
@@ -49,6 +55,7 @@ interface NavLinkItem {
|
|||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
labelKey: BusinessMessageKey
|
labelKey: BusinessMessageKey
|
||||||
to: string
|
to: string
|
||||||
|
moduleId?: BusinessModuleId
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavItem = NavLinkItem | NavGroup
|
type NavItem = NavLinkItem | NavGroup
|
||||||
@@ -62,6 +69,7 @@ export function Sidebar() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
|
const { enabledModules } = useTenantBranding()
|
||||||
const t = useT()
|
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)
|
||||||
@@ -79,96 +87,104 @@ export function Sidebar() {
|
|||||||
}, [locale, nameEn, nameFa, fallbackBusinessName])
|
}, [locale, nameEn, nameFa, fallbackBusinessName])
|
||||||
|
|
||||||
const navItems = useMemo<NavItem[]>(
|
const navItems = useMemo<NavItem[]>(
|
||||||
() => [
|
() =>
|
||||||
{ type: 'link', id: 'home', icon: Home, labelKey: 'nav.home', to: '/' },
|
[
|
||||||
{
|
{ type: 'link', id: 'home', icon: Home, labelKey: 'nav.home', to: '/' },
|
||||||
type: 'link',
|
{
|
||||||
id: 'business-profile',
|
type: 'link',
|
||||||
icon: Building2,
|
id: 'business-profile',
|
||||||
labelKey: 'nav.businessProfile',
|
icon: Building2,
|
||||||
to: '/business-profile',
|
labelKey: 'nav.businessProfile',
|
||||||
},
|
to: '/business-profile',
|
||||||
{
|
},
|
||||||
type: 'group',
|
{
|
||||||
id: 'products',
|
type: 'group',
|
||||||
icon: ShoppingBag,
|
id: 'products',
|
||||||
labelKey: 'nav.products',
|
icon: ShoppingBag,
|
||||||
basePath: '/products',
|
labelKey: 'nav.products',
|
||||||
children: [
|
basePath: '/products',
|
||||||
{ labelKey: 'nav.products.overview', to: '/products' },
|
moduleId: 'products',
|
||||||
{ labelKey: 'nav.products.list', to: '/products/list' },
|
children: [
|
||||||
{ labelKey: 'nav.products.new', to: '/products/new' },
|
{ labelKey: 'nav.products.overview', to: '/products' },
|
||||||
{ labelKey: 'nav.products.categories', to: '/products/categories' },
|
{ labelKey: 'nav.products.list', to: '/products/list' },
|
||||||
{ labelKey: 'nav.products.brands', to: '/products/brands' },
|
{ labelKey: 'nav.products.new', to: '/products/new' },
|
||||||
{ labelKey: 'nav.products.settings', to: '/products/settings' },
|
{ 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,
|
type: 'group',
|
||||||
labelKey: 'nav.store',
|
id: 'store',
|
||||||
basePath: '/store',
|
icon: Store,
|
||||||
children: [
|
labelKey: 'nav.store',
|
||||||
{ labelKey: 'nav.store.overview', to: '/store' },
|
basePath: '/store',
|
||||||
{ labelKey: 'nav.store.items', to: '/store/items' },
|
moduleId: 'store',
|
||||||
{ labelKey: 'nav.store.orders', to: '/store/orders' },
|
children: [
|
||||||
{ labelKey: 'nav.store.shipping', to: '/store/shipping' },
|
{ labelKey: 'nav.store.overview', to: '/store' },
|
||||||
{ labelKey: 'nav.store.cards', to: '/store/cards' },
|
{ labelKey: 'nav.store.items', to: '/store/items' },
|
||||||
{ labelKey: 'nav.store.settings', to: '/store/settings' },
|
{ labelKey: 'nav.store.orders', to: '/store/orders' },
|
||||||
],
|
{ labelKey: 'nav.store.shipping', to: '/store/shipping' },
|
||||||
},
|
{ labelKey: 'nav.store.cards', to: '/store/cards' },
|
||||||
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
{ labelKey: 'nav.store.settings', to: '/store/settings' },
|
||||||
{
|
],
|
||||||
type: 'group',
|
},
|
||||||
id: 'blog',
|
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
||||||
icon: FileText,
|
{
|
||||||
labelKey: 'nav.blog',
|
type: 'group',
|
||||||
basePath: '/blog',
|
id: 'blog',
|
||||||
children: [
|
icon: FileText,
|
||||||
{ labelKey: 'nav.blog.overview', to: '/blog' },
|
labelKey: 'nav.blog',
|
||||||
{ labelKey: 'nav.blog.list', to: '/blog/list' },
|
basePath: '/blog',
|
||||||
{ labelKey: 'nav.blog.new', to: '/blog/new' },
|
moduleId: 'blog',
|
||||||
{ labelKey: 'nav.blog.categories', to: '/blog/categories' },
|
children: [
|
||||||
{ labelKey: 'nav.blog.settings', to: '/blog/settings' },
|
{ 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' },
|
||||||
type: 'group',
|
{ labelKey: 'nav.blog.settings', to: '/blog/settings' },
|
||||||
id: 'portfolios',
|
],
|
||||||
icon: Briefcase,
|
},
|
||||||
labelKey: 'nav.portfolios',
|
{
|
||||||
basePath: '/portfolios',
|
type: 'group',
|
||||||
children: [
|
id: 'portfolios',
|
||||||
{ labelKey: 'nav.portfolios.overview', to: '/portfolios' },
|
icon: Briefcase,
|
||||||
{ labelKey: 'nav.portfolios.list', to: '/portfolios/list' },
|
labelKey: 'nav.portfolios',
|
||||||
{ labelKey: 'nav.portfolios.new', to: '/portfolios/new' },
|
basePath: '/portfolios',
|
||||||
{ labelKey: 'nav.portfolios.categories', to: '/portfolios/categories' },
|
moduleId: 'portfolio',
|
||||||
{ labelKey: 'nav.portfolios.settings', to: '/portfolios/settings' },
|
children: [
|
||||||
],
|
{ labelKey: 'nav.portfolios.overview', to: '/portfolios' },
|
||||||
},
|
{ labelKey: 'nav.portfolios.list', to: '/portfolios/list' },
|
||||||
{
|
{ labelKey: 'nav.portfolios.new', to: '/portfolios/new' },
|
||||||
type: 'group',
|
{ labelKey: 'nav.portfolios.categories', to: '/portfolios/categories' },
|
||||||
id: 'website',
|
{ labelKey: 'nav.portfolios.settings', to: '/portfolios/settings' },
|
||||||
icon: Globe,
|
],
|
||||||
labelKey: 'nav.website',
|
},
|
||||||
basePath: '/website',
|
{
|
||||||
children: [
|
type: 'group',
|
||||||
{ labelKey: 'nav.website.overview', to: '/website' },
|
id: 'website',
|
||||||
{ labelKey: 'nav.website.sliders', to: '/website/sliders' },
|
icon: Globe,
|
||||||
{ labelKey: 'nav.website.specialCategories', to: '/website/special-categories' },
|
labelKey: 'nav.website',
|
||||||
{ labelKey: 'nav.website.specialBrands', to: '/website/special-brands' },
|
basePath: '/website',
|
||||||
{ labelKey: 'nav.website.specialItems', to: '/website/special-items' },
|
children: [
|
||||||
{ labelKey: 'nav.website.contact', to: '/website/contact' },
|
{ labelKey: 'nav.website.overview', to: '/website' },
|
||||||
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
|
{ labelKey: 'nav.website.sliders', to: '/website/sliders' },
|
||||||
{ labelKey: 'nav.website.faq', to: '/website/faq' },
|
{ labelKey: 'nav.website.specialCategories', to: '/website/special-categories' },
|
||||||
{ labelKey: 'nav.website.badges', to: '/website/badges' },
|
{ labelKey: 'nav.website.specialBrands', to: '/website/special-brands' },
|
||||||
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
|
{ labelKey: 'nav.website.specialItems', to: '/website/special-items' },
|
||||||
],
|
{ labelKey: 'nav.website.contact', to: '/website/contact' },
|
||||||
},
|
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
|
||||||
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
|
{ labelKey: 'nav.website.faq', to: '/website/faq' },
|
||||||
],
|
{ labelKey: 'nav.website.badges', to: '/website/badges' },
|
||||||
[],
|
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
|
||||||
|
].filter(
|
||||||
|
(item) =>
|
||||||
|
!item.moduleId || hasBusinessModule(enabledModules, item.moduleId),
|
||||||
|
),
|
||||||
|
[enabledModules],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -15,12 +15,22 @@ import { getBusinessDomain } from '../lib/config'
|
|||||||
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
|
import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext'
|
||||||
import { getBusinessProfile } from '../services/businessProfileService'
|
import { getBusinessProfile } from '../services/businessProfileService'
|
||||||
import { resolveTenantByDomain } from '../services/tenantService'
|
import { resolveTenantByDomain } from '../services/tenantService'
|
||||||
|
import {
|
||||||
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
DEFAULT_HOME_CHARTS,
|
||||||
|
normalizeEnabledBusinessModules,
|
||||||
|
normalizeHomeCharts,
|
||||||
|
type BusinessModuleId,
|
||||||
|
type HomeChartId,
|
||||||
|
} from '../utils/businessModules'
|
||||||
|
|
||||||
interface TenantBrandingContextValue {
|
interface TenantBrandingContextValue {
|
||||||
businessName: string
|
businessName: string
|
||||||
businessNameEn: string
|
businessNameEn: string
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
faviconUrl: string | null
|
faviconUrl: string | null
|
||||||
|
enabledModules: BusinessModuleId[]
|
||||||
|
homeCharts: [HomeChartId, HomeChartId]
|
||||||
refreshBranding: () => void
|
refreshBranding: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +52,12 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
const [nameFa, setNameFa] = 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 [enabledModules, setEnabledModules] = useState<BusinessModuleId[]>(
|
||||||
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
)
|
||||||
|
const [homeCharts, setHomeCharts] = useState<[HomeChartId, HomeChartId]>([
|
||||||
|
...DEFAULT_HOME_CHARTS,
|
||||||
|
])
|
||||||
const [refreshToken, setRefreshToken] = useState(0)
|
const [refreshToken, setRefreshToken] = useState(0)
|
||||||
const defaultLocaleAppliedRef = useRef(false)
|
const defaultLocaleAppliedRef = useRef(false)
|
||||||
|
|
||||||
@@ -95,6 +111,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
setNameFa(nextNameFa || nextNameEn || domain)
|
setNameFa(nextNameFa || nextNameEn || domain)
|
||||||
setLogoUrl(nextLogo)
|
setLogoUrl(nextLogo)
|
||||||
setFaviconUrl(nextFavicon)
|
setFaviconUrl(nextFavicon)
|
||||||
|
setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules))
|
||||||
|
setHomeCharts(normalizeHomeCharts(tenant.homeCharts))
|
||||||
applyDocumentFavicon(nextFavicon)
|
applyDocumentFavicon(nextFavicon)
|
||||||
if (!defaultLocaleAppliedRef.current) {
|
if (!defaultLocaleAppliedRef.current) {
|
||||||
defaultLocaleAppliedRef.current = true
|
defaultLocaleAppliedRef.current = true
|
||||||
@@ -112,6 +130,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
setNameFa(domain)
|
setNameFa(domain)
|
||||||
setLogoUrl(null)
|
setLogoUrl(null)
|
||||||
setFaviconUrl(null)
|
setFaviconUrl(null)
|
||||||
|
setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES])
|
||||||
|
setHomeCharts([...DEFAULT_HOME_CHARTS])
|
||||||
applyDocumentFavicon(null)
|
applyDocumentFavicon(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,8 +162,24 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ businessName, businessNameEn, logoUrl, faviconUrl, refreshBranding }),
|
() => ({
|
||||||
[businessName, businessNameEn, logoUrl, faviconUrl, refreshBranding],
|
businessName,
|
||||||
|
businessNameEn,
|
||||||
|
logoUrl,
|
||||||
|
faviconUrl,
|
||||||
|
enabledModules,
|
||||||
|
homeCharts,
|
||||||
|
refreshBranding,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
businessName,
|
||||||
|
businessNameEn,
|
||||||
|
logoUrl,
|
||||||
|
faviconUrl,
|
||||||
|
enabledModules,
|
||||||
|
homeCharts,
|
||||||
|
refreshBranding,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -192,6 +192,19 @@ const en = {
|
|||||||
'home.chart.customers.error': 'Unable to load customer activity.',
|
'home.chart.customers.error': 'Unable to load customer activity.',
|
||||||
'home.chart.customers.bar': '{day}: {count} registered',
|
'home.chart.customers.bar': '{day}: {count} registered',
|
||||||
'home.chart.customers.activeBar': '{day}: {count} active',
|
'home.chart.customers.activeBar': '{day}: {count} active',
|
||||||
|
'home.chart.customersJoined.title': 'Customers joined',
|
||||||
|
'home.chart.customersJoined.subtitle': 'Registrations and active users in the last year',
|
||||||
|
'home.chart.customersJoined.legend': 'Registered ({count})',
|
||||||
|
'home.chart.customersJoined.activeLegend': 'Active ({count})',
|
||||||
|
'home.chart.customersJoined.loading': 'Loading chart...',
|
||||||
|
'home.chart.customersJoined.error': 'Unable to load customer activity.',
|
||||||
|
'home.chart.customersJoined.bar': '{day}: {count} registered',
|
||||||
|
'home.chart.customersJoined.activeBar': '{day}: {count} active',
|
||||||
|
'home.chart.blogViews.title': 'Article views',
|
||||||
|
'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days',
|
||||||
|
'home.chart.placeholder.title': 'Chart',
|
||||||
|
'home.chart.placeholder.subtitle': 'This chart will appear here once available.',
|
||||||
|
'home.chart.placeholder.comingSoon': 'Coming soon',
|
||||||
|
|
||||||
'products.overview.subtitle': 'Manage your products, inventory and categories.',
|
'products.overview.subtitle': 'Manage your products, inventory and categories.',
|
||||||
'products.card.list.desc': 'View, edit and manage all your existing products.',
|
'products.card.list.desc': 'View, edit and manage all your existing products.',
|
||||||
@@ -1231,7 +1244,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'home.card.website.link': 'مشاهده وبسایت',
|
'home.card.website.link': 'مشاهده وبسایت',
|
||||||
|
|
||||||
'home.chart.orders.title': 'سفارشها',
|
'home.chart.orders.title': 'سفارشها',
|
||||||
'home.chart.orders.subtitle': 'سفارشها و افزودن به سبد در ۳۰ روز گذشته',
|
'home.chart.orders.subtitle': 'سفارشها در ۳۰ روز گذشته',
|
||||||
'home.chart.orders.legend': 'سفارش ({count})',
|
'home.chart.orders.legend': 'سفارش ({count})',
|
||||||
'home.chart.orders.cartLegend': 'افزودن به سبد ({count})',
|
'home.chart.orders.cartLegend': 'افزودن به سبد ({count})',
|
||||||
'home.chart.orders.loading': 'در حال بارگذاری نمودار...',
|
'home.chart.orders.loading': 'در حال بارگذاری نمودار...',
|
||||||
@@ -1246,6 +1259,19 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'home.chart.customers.error': 'بارگذاری فعالیت مشتریان ممکن نشد.',
|
'home.chart.customers.error': 'بارگذاری فعالیت مشتریان ممکن نشد.',
|
||||||
'home.chart.customers.bar': '{day}: {count} ثبتنام',
|
'home.chart.customers.bar': '{day}: {count} ثبتنام',
|
||||||
'home.chart.customers.activeBar': '{day}: {count} فعال',
|
'home.chart.customers.activeBar': '{day}: {count} فعال',
|
||||||
|
'home.chart.customersJoined.title': 'مشتریان عضو شده',
|
||||||
|
'home.chart.customersJoined.subtitle': 'مشتریان عضو شده در ۱ سال گذشته',
|
||||||
|
'home.chart.customersJoined.legend': 'ثبتنام ({count})',
|
||||||
|
'home.chart.customersJoined.activeLegend': 'فعال ({count})',
|
||||||
|
'home.chart.customersJoined.loading': 'در حال بارگذاری نمودار...',
|
||||||
|
'home.chart.customersJoined.error': 'بارگذاری فعالیت مشتریان ممکن نشد.',
|
||||||
|
'home.chart.customersJoined.bar': '{day}: {count} ثبتنام',
|
||||||
|
'home.chart.customersJoined.activeBar': '{day}: {count} فعال',
|
||||||
|
'home.chart.blogViews.title': 'بازدید مقالات',
|
||||||
|
'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته',
|
||||||
|
'home.chart.placeholder.title': 'نمودار',
|
||||||
|
'home.chart.placeholder.subtitle': 'این نمودار بهزودی در دسترس خواهد بود.',
|
||||||
|
'home.chart.placeholder.comingSoon': 'بهزودی',
|
||||||
|
|
||||||
'products.overview.subtitle': 'محصولات، موجودی و دستهبندیها را مدیریت کنید.',
|
'products.overview.subtitle': 'محصولات، موجودی و دستهبندیها را مدیریت کنید.',
|
||||||
'products.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.',
|
'products.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { CalendarDays } from 'lucide-react'
|
import { CalendarDays } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
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 { HomeChartSlot } from '../components/HomeChartSlot'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { BusinessMessageKey } from '../i18n/messages'
|
import type { BusinessMessageKey } from '../i18n/messages'
|
||||||
import { listProducts } from '../services/productService'
|
import { listProducts } from '../services/productService'
|
||||||
@@ -19,10 +19,12 @@ import { listStoreItems } from '../services/storeItemService'
|
|||||||
import { listCustomers } from '../services/customerService'
|
import { listCustomers } from '../services/customerService'
|
||||||
import { listBlogs } from '../services/blogService'
|
import { listBlogs } from '../services/blogService'
|
||||||
import { listPortfolios } from '../services/portfolioService'
|
import { listPortfolios } from '../services/portfolioService'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import {
|
import {
|
||||||
getCustomersDailyActivity,
|
hasBusinessModule,
|
||||||
getOrdersDailyActivity,
|
isActiveHomeChart,
|
||||||
} from '../services/dailyActivityService'
|
type BusinessModuleId,
|
||||||
|
} from '../utils/businessModules'
|
||||||
import styles from '../components/PageContent.module.css'
|
import styles from '../components/PageContent.module.css'
|
||||||
|
|
||||||
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios'
|
type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios'
|
||||||
@@ -35,6 +37,8 @@ const sections: {
|
|||||||
countLabelKey?: BusinessMessageKey
|
countLabelKey?: BusinessMessageKey
|
||||||
href: string
|
href: string
|
||||||
countKey?: CountKey
|
countKey?: CountKey
|
||||||
|
/** When set, card is shown only if this optional module is enabled. */
|
||||||
|
moduleId?: BusinessModuleId
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
icon: ShoppingBag,
|
icon: ShoppingBag,
|
||||||
@@ -44,6 +48,7 @@ const sections: {
|
|||||||
countLabelKey: 'home.card.products.count',
|
countLabelKey: 'home.card.products.count',
|
||||||
href: '/products',
|
href: '/products',
|
||||||
countKey: 'products',
|
countKey: 'products',
|
||||||
|
moduleId: 'products',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Store,
|
icon: Store,
|
||||||
@@ -53,6 +58,7 @@ const sections: {
|
|||||||
countLabelKey: 'home.card.store.count',
|
countLabelKey: 'home.card.store.count',
|
||||||
href: '/store',
|
href: '/store',
|
||||||
countKey: 'store',
|
countKey: 'store',
|
||||||
|
moduleId: 'store',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@@ -71,6 +77,7 @@ const sections: {
|
|||||||
countLabelKey: 'home.card.blog.count',
|
countLabelKey: 'home.card.blog.count',
|
||||||
href: '/blog',
|
href: '/blog',
|
||||||
countKey: 'blog',
|
countKey: 'blog',
|
||||||
|
moduleId: 'blog',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
@@ -80,6 +87,7 @@ const sections: {
|
|||||||
countLabelKey: 'home.card.portfolios.count',
|
countLabelKey: 'home.card.portfolios.count',
|
||||||
href: '/portfolios',
|
href: '/portfolios',
|
||||||
countKey: 'portfolios',
|
countKey: 'portfolios',
|
||||||
|
moduleId: 'portfolio',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
@@ -113,6 +121,7 @@ async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
|
|||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
|
const { enabledModules, homeCharts } = useTenantBranding()
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const [counts, setCounts] = useState<SectionCounts>({})
|
const [counts, setCounts] = useState<SectionCounts>({})
|
||||||
|
|
||||||
@@ -124,14 +133,11 @@ export function HomePage() {
|
|||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadOrdersActivity = useCallback(
|
const visibleSections = sections.filter(
|
||||||
(signal: AbortSignal) => getOrdersDailyActivity(30, signal),
|
(section) =>
|
||||||
[],
|
!section.moduleId || hasBusinessModule(enabledModules, section.moduleId),
|
||||||
)
|
|
||||||
const loadCustomersActivity = useCallback(
|
|
||||||
(signal: AbortSignal) => getCustomersDailyActivity(30, signal),
|
|
||||||
[],
|
|
||||||
)
|
)
|
||||||
|
const visibleCharts = homeCharts.filter(isActiveHomeChart)
|
||||||
|
|
||||||
const firstName =
|
const firstName =
|
||||||
(locale === 'en'
|
(locale === 'en'
|
||||||
@@ -160,7 +166,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.gridHome}>
|
<div className={styles.gridHome}>
|
||||||
{sections.map((section) => (
|
{visibleSections.map((section) => (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
key={section.href}
|
key={section.href}
|
||||||
icon={section.icon}
|
icon={section.icon}
|
||||||
@@ -174,34 +180,15 @@ export function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.grid12}>
|
{visibleCharts.length > 0 ? (
|
||||||
<div className={styles.col6}>
|
<div className={styles.grid12}>
|
||||||
<DailyActivityChart
|
{visibleCharts.map((chartId, index) => (
|
||||||
titleKey="home.chart.orders.title"
|
<div key={`${chartId}-${index}`} className={styles.col6}>
|
||||||
subtitleKey="home.chart.orders.subtitle"
|
<HomeChartSlot chartId={chartId} />
|
||||||
primaryLegendKey="home.chart.orders.legend"
|
</div>
|
||||||
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>
|
||||||
<div className={styles.col6}>
|
) : null}
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,15 @@ export interface StoreSettings {
|
|||||||
orderProcessSteps: OrderProcessStep[]
|
orderProcessSteps: OrderProcessStep[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModulesSettings {
|
||||||
|
enabled: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface BusinessSettings {
|
export interface BusinessSettings {
|
||||||
branding: BrandingSettings
|
branding: BrandingSettings
|
||||||
dashboard: DashboardSettings
|
dashboard: DashboardSettings
|
||||||
store: StoreSettings
|
store: StoreSettings
|
||||||
|
modules?: ModulesSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiRequest } from '../lib/api'
|
import { apiRequest } from '../lib/api'
|
||||||
import type { DashboardLocale } from '@meshkee/dashboard-core'
|
import type { DashboardLocale } from '@meshkee/dashboard-core'
|
||||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||||
|
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
||||||
|
|
||||||
export interface ResolvedTenant {
|
export interface ResolvedTenant {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,6 +11,8 @@ export interface ResolvedTenant {
|
|||||||
domain: string
|
domain: string
|
||||||
primaryColor: BusinessPrimaryColorId
|
primaryColor: BusinessPrimaryColorId
|
||||||
defaultLocale?: DashboardLocale
|
defaultLocale?: DashboardLocale
|
||||||
|
enabledModules?: BusinessModuleId[]
|
||||||
|
homeCharts?: [HomeChartId, HomeChartId]
|
||||||
logoUrl?: string | null
|
logoUrl?: string | null
|
||||||
faviconUrl?: string | null
|
faviconUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/** Optional CMS modules a business can have. Always-on areas are not listed. */
|
||||||
|
export const BUSINESS_MODULE_IDS = [
|
||||||
|
'products',
|
||||||
|
'store',
|
||||||
|
'portfolio',
|
||||||
|
'blog',
|
||||||
|
'warehouse',
|
||||||
|
'videos',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
|
||||||
|
|
||||||
|
/** Home dashboard chart slots (super-admin selectable). */
|
||||||
|
export const HOME_CHART_IDS = [
|
||||||
|
'none',
|
||||||
|
'orders_30d',
|
||||||
|
'customers_joined_1y',
|
||||||
|
'blog_views_30d',
|
||||||
|
'products_added_1y',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type HomeChartId = (typeof HOME_CHART_IDS)[number]
|
||||||
|
|
||||||
|
/** Existing tenants without saved modules keep every module enabled. */
|
||||||
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||||
|
...BUSINESS_MODULE_IDS,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
|
'orders_30d',
|
||||||
|
'customers_joined_1y',
|
||||||
|
]
|
||||||
|
|
||||||
|
const MODULE_ID_SET = new Set<string>(BUSINESS_MODULE_IDS)
|
||||||
|
const HOME_CHART_ID_SET = new Set<string>(HOME_CHART_IDS)
|
||||||
|
|
||||||
|
export function isBusinessModuleId(value: unknown): value is BusinessModuleId {
|
||||||
|
return typeof value === 'string' && MODULE_ID_SET.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHomeChartId(value: unknown): value is HomeChartId {
|
||||||
|
return typeof value === 'string' && HOME_CHART_ID_SET.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEnabledBusinessModules(value: unknown): BusinessModuleId[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...DEFAULT_ENABLED_BUSINESS_MODULES]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = new Set<BusinessModuleId>()
|
||||||
|
for (const item of value) {
|
||||||
|
if (isBusinessModuleId(item)) selected.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BUSINESS_MODULE_IDS.filter((id) => selected.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHomeCharts(value: unknown): [HomeChartId, HomeChartId] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...DEFAULT_HOME_CHARTS]
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = isHomeChartId(value[0]) ? value[0] : DEFAULT_HOME_CHARTS[0]
|
||||||
|
const second = isHomeChartId(value[1]) ? value[1] : DEFAULT_HOME_CHARTS[1]
|
||||||
|
return [first, second]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBusinessModule(
|
||||||
|
enabledModules: readonly BusinessModuleId[] | null | undefined,
|
||||||
|
moduleId: BusinessModuleId,
|
||||||
|
): boolean {
|
||||||
|
const enabled = normalizeEnabledBusinessModules(enabledModules)
|
||||||
|
return enabled.includes(moduleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActiveHomeChart(value: HomeChartId): boolean {
|
||||||
|
return value !== 'none'
|
||||||
|
}
|
||||||
@@ -414,6 +414,94 @@
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thModules,
|
||||||
|
.tdModules {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdModules {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCountBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) 12px;
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCountBtn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCountBtn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesChecks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCharts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCharts .field select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
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);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
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);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCharts .field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesCharts .field select:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.localeSelect {
|
.localeSelect {
|
||||||
min-height: var(--field-height);
|
min-height: var(--field-height);
|
||||||
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y)
|
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y)
|
||||||
|
|||||||
@@ -55,9 +55,22 @@ import {
|
|||||||
getBusinessSettings,
|
getBusinessSettings,
|
||||||
updateBusinessBranding,
|
updateBusinessBranding,
|
||||||
updateBusinessDefaultLocale,
|
updateBusinessDefaultLocale,
|
||||||
|
updateBusinessModules,
|
||||||
updateBusinessPrimaryColor,
|
updateBusinessPrimaryColor,
|
||||||
type DashboardLocale,
|
type DashboardLocale,
|
||||||
} from '../services/businessSettingsService'
|
} from '../services/businessSettingsService'
|
||||||
|
import {
|
||||||
|
BUSINESS_MODULE_IDS,
|
||||||
|
BUSINESS_MODULE_LABELS,
|
||||||
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
DEFAULT_HOME_CHARTS,
|
||||||
|
HOME_CHART_IDS,
|
||||||
|
HOME_CHART_LABELS,
|
||||||
|
normalizeEnabledBusinessModules,
|
||||||
|
normalizeHomeCharts,
|
||||||
|
type BusinessModuleId,
|
||||||
|
type HomeChartId,
|
||||||
|
} from '../utils/businessModules'
|
||||||
import { flattenBusinessCategories } from '../utils/categories'
|
import { flattenBusinessCategories } from '../utils/categories'
|
||||||
import { Pagination } from '@meshkee/dashboard-ui'
|
import { Pagination } from '@meshkee/dashboard-ui'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
@@ -70,6 +83,15 @@ function normalizeDefaultLocale(value: unknown): DashboardLocale {
|
|||||||
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
|
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyModuleSelection(
|
||||||
|
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
): Record<BusinessModuleId, boolean> {
|
||||||
|
const selected = new Set(enabled)
|
||||||
|
return Object.fromEntries(
|
||||||
|
BUSINESS_MODULE_IDS.map((id) => [id, selected.has(id)]),
|
||||||
|
) as Record<BusinessModuleId, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
const d = new Date(value)
|
const d = new Date(value)
|
||||||
if (Number.isNaN(d.getTime())) return value
|
if (Number.isNaN(d.getTime())) return value
|
||||||
@@ -227,6 +249,17 @@ export function BusinessesPage() {
|
|||||||
const [savingColorId, setSavingColorId] = useState<string | null>(null)
|
const [savingColorId, setSavingColorId] = useState<string | null>(null)
|
||||||
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
|
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [modulesOpen, setModulesOpen] = useState(false)
|
||||||
|
const [modulesBusiness, setModulesBusiness] = useState<BusinessListItem | null>(null)
|
||||||
|
const [modulesSelected, setModulesSelected] = useState<Record<BusinessModuleId, boolean>>(
|
||||||
|
() => emptyModuleSelection(),
|
||||||
|
)
|
||||||
|
const [modulesCharts, setModulesCharts] = useState<[HomeChartId, HomeChartId]>([
|
||||||
|
...DEFAULT_HOME_CHARTS,
|
||||||
|
])
|
||||||
|
const [modulesSubmitting, setModulesSubmitting] = useState(false)
|
||||||
|
const [modulesError, setModulesError] = useState('')
|
||||||
|
|
||||||
const [migrateOpen, setMigrateOpen] = useState(false)
|
const [migrateOpen, setMigrateOpen] = useState(false)
|
||||||
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
|
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
|
||||||
const [migrateOldId, setMigrateOldId] = useState('')
|
const [migrateOldId, setMigrateOldId] = useState('')
|
||||||
@@ -465,6 +498,62 @@ export function BusinessesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openModules(b: BusinessListItem) {
|
||||||
|
setModulesBusiness(b)
|
||||||
|
setModulesSelected(
|
||||||
|
emptyModuleSelection(
|
||||||
|
normalizeEnabledBusinessModules(b.enabledModules ?? DEFAULT_ENABLED_BUSINESS_MODULES),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setModulesCharts(normalizeHomeCharts(b.homeCharts ?? DEFAULT_HOME_CHARTS))
|
||||||
|
setModulesError('')
|
||||||
|
setModulesOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModules() {
|
||||||
|
if (modulesSubmitting) return
|
||||||
|
setModulesOpen(false)
|
||||||
|
setModulesBusiness(null)
|
||||||
|
setModulesError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitModules() {
|
||||||
|
if (!modulesBusiness) return
|
||||||
|
const enabled = BUSINESS_MODULE_IDS.filter((id) => modulesSelected[id])
|
||||||
|
const charts = normalizeHomeCharts(modulesCharts)
|
||||||
|
setModulesSubmitting(true)
|
||||||
|
setModulesError('')
|
||||||
|
try {
|
||||||
|
await updateBusinessModules(modulesBusiness.id, enabled, charts)
|
||||||
|
setData((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.map((item) =>
|
||||||
|
item.id === modulesBusiness.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
enabledModules: enabled,
|
||||||
|
moduleCount: enabled.length,
|
||||||
|
homeCharts: charts,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
showToast('Modules updated.', 'success')
|
||||||
|
setModulesOpen(false)
|
||||||
|
setModulesBusiness(null)
|
||||||
|
} catch (err) {
|
||||||
|
setModulesError(
|
||||||
|
err instanceof ApiError ? err.message : 'Unable to update modules.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setModulesSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
if (!editBusiness) return
|
if (!editBusiness) return
|
||||||
setEditSubmitting(true)
|
setEditSubmitting(true)
|
||||||
@@ -903,13 +992,14 @@ export function BusinessesPage() {
|
|||||||
<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.thLocale}`}>Lang</th>
|
||||||
|
<th className={`${styles.th} ${styles.thModules}`}>Modules</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={7}>
|
<td className={styles.td} colSpan={8}>
|
||||||
Loading...
|
Loading...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -917,7 +1007,7 @@ export function BusinessesPage() {
|
|||||||
|
|
||||||
{!loading && data?.items?.length === 0 && (
|
{!loading && data?.items?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className={styles.td} colSpan={7}>
|
<td className={styles.td} colSpan={8}>
|
||||||
No results found.
|
No results found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -992,6 +1082,21 @@ export function BusinessesPage() {
|
|||||||
<option value="en">EN</option>
|
<option value="en">EN</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
<td className={`${styles.td} ${styles.tdModules}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.modulesCountBtn}
|
||||||
|
onClick={() => openModules(b)}
|
||||||
|
title="Edit modules"
|
||||||
|
aria-label={`Edit modules for ${b.name}`}
|
||||||
|
>
|
||||||
|
{typeof b.moduleCount === 'number'
|
||||||
|
? b.moduleCount
|
||||||
|
: normalizeEnabledBusinessModules(
|
||||||
|
b.enabledModules ?? DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
).length}
|
||||||
|
</button>
|
||||||
|
</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}>
|
||||||
@@ -1212,6 +1317,104 @@ export function BusinessesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={modulesOpen}
|
||||||
|
title={
|
||||||
|
modulesBusiness
|
||||||
|
? `Modules — ${modulesBusiness.name}`
|
||||||
|
: 'Modules'
|
||||||
|
}
|
||||||
|
onClose={closeModules}
|
||||||
|
>
|
||||||
|
{modulesError ? (
|
||||||
|
<p className={styles.alertError} role="alert">
|
||||||
|
{modulesError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className={styles.helperText}>
|
||||||
|
Choose which optional modules this business can use. Customers, website,
|
||||||
|
and other general sections stay available for every business.
|
||||||
|
</p>
|
||||||
|
<div className={styles.modulesChecks}>
|
||||||
|
{BUSINESS_MODULE_IDS.map((id) => (
|
||||||
|
<label key={id} className={styles.entityCheck}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={modulesSelected[id]}
|
||||||
|
disabled={modulesSubmitting}
|
||||||
|
onChange={(e) =>
|
||||||
|
setModulesSelected((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{BUSINESS_MODULE_LABELS[id]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modulesCharts}>
|
||||||
|
<p className={styles.entityGroupLabel}>Home charts</p>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="modules-chart-1">Chart 1</label>
|
||||||
|
<select
|
||||||
|
id="modules-chart-1"
|
||||||
|
value={modulesCharts[0]}
|
||||||
|
disabled={modulesSubmitting}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
|
||||||
|
setModulesCharts([value as HomeChartId, modulesCharts[1]])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HOME_CHART_IDS.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{HOME_CHART_LABELS[id]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="modules-chart-2">Chart 2</label>
|
||||||
|
<select
|
||||||
|
id="modules-chart-2"
|
||||||
|
value={modulesCharts[1]}
|
||||||
|
disabled={modulesSubmitting}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
if (!HOME_CHART_IDS.includes(value as HomeChartId)) return
|
||||||
|
setModulesCharts([modulesCharts[0], value as HomeChartId])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HOME_CHART_IDS.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{HOME_CHART_LABELS[id]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 12 }} />
|
||||||
|
<div className={styles.actionsRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.btn} ${styles.btnGhost}`}
|
||||||
|
onClick={closeModules}
|
||||||
|
disabled={modulesSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||||
|
onClick={() => void submitModules()}
|
||||||
|
disabled={modulesSubmitting}
|
||||||
|
>
|
||||||
|
{modulesSubmitting ? 'Saving…' : 'Save modules'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={migrateOpen}
|
open={migrateOpen}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiRequest } from '../lib/api'
|
import { apiRequest } from '../lib/api'
|
||||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||||
|
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
||||||
|
|
||||||
export type DashboardLocale = 'en' | 'fa'
|
export type DashboardLocale = 'en' | 'fa'
|
||||||
|
|
||||||
@@ -8,6 +9,11 @@ export interface BrandingSettings {
|
|||||||
defaultLocale: DashboardLocale
|
defaultLocale: DashboardLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModulesSettings {
|
||||||
|
enabled: BusinessModuleId[]
|
||||||
|
charts: [HomeChartId, HomeChartId]
|
||||||
|
}
|
||||||
|
|
||||||
export interface BusinessSettings {
|
export interface BusinessSettings {
|
||||||
branding: BrandingSettings
|
branding: BrandingSettings
|
||||||
dashboard: {
|
dashboard: {
|
||||||
@@ -18,6 +24,7 @@ export interface BusinessSettings {
|
|||||||
onlineSellEnabled: boolean
|
onlineSellEnabled: boolean
|
||||||
orderProcessSteps: Array<{ id: string; label: string; color: string }>
|
orderProcessSteps: Array<{ id: string; label: string; color: string }>
|
||||||
}
|
}
|
||||||
|
modules: ModulesSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessSettingsResponse {
|
export interface BusinessSettingsResponse {
|
||||||
@@ -68,3 +75,17 @@ export async function updateBusinessBranding(
|
|||||||
body: { branding },
|
body: { branding },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateBusinessModules(
|
||||||
|
businessId: string,
|
||||||
|
enabled: BusinessModuleId[],
|
||||||
|
charts: [HomeChartId, HomeChartId],
|
||||||
|
) {
|
||||||
|
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
modules: { enabled, charts },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||||
|
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
||||||
import type { DashboardLocale } from '../services/businessSettingsService'
|
import type { DashboardLocale } from '../services/businessSettingsService'
|
||||||
|
|
||||||
export interface BusinessOwnerInfo {
|
export interface BusinessOwnerInfo {
|
||||||
@@ -24,6 +25,9 @@ export interface BusinessListItem {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
primaryColor: BusinessPrimaryColorId
|
primaryColor: BusinessPrimaryColorId
|
||||||
defaultLocale: DashboardLocale
|
defaultLocale: DashboardLocale
|
||||||
|
enabledModules: BusinessModuleId[]
|
||||||
|
moduleCount: number
|
||||||
|
homeCharts: [HomeChartId, HomeChartId]
|
||||||
oldBusinessId: string | null
|
oldBusinessId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/** Optional CMS modules a business can have. Always-on areas are not listed. */
|
||||||
|
export const BUSINESS_MODULE_IDS = [
|
||||||
|
'products',
|
||||||
|
'store',
|
||||||
|
'portfolio',
|
||||||
|
'blog',
|
||||||
|
'warehouse',
|
||||||
|
'videos',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
|
||||||
|
|
||||||
|
export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
|
||||||
|
products: 'Products',
|
||||||
|
store: 'Store',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
blog: 'Blog',
|
||||||
|
warehouse: 'Warehouse',
|
||||||
|
videos: 'Videos',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Home dashboard chart slots (super-admin selectable). */
|
||||||
|
export const HOME_CHART_IDS = [
|
||||||
|
'none',
|
||||||
|
'orders_30d',
|
||||||
|
'customers_joined_1y',
|
||||||
|
'blog_views_30d',
|
||||||
|
'products_added_1y',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type HomeChartId = (typeof HOME_CHART_IDS)[number]
|
||||||
|
|
||||||
|
export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
|
||||||
|
none: 'No chart',
|
||||||
|
orders_30d: 'Orders in past 30 days',
|
||||||
|
customers_joined_1y: 'Joined users in last year',
|
||||||
|
blog_views_30d: 'Blog visit in last month',
|
||||||
|
products_added_1y: 'Added product in last year',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing tenants without saved modules keep every module enabled. */
|
||||||
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||||
|
...BUSINESS_MODULE_IDS,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
|
'orders_30d',
|
||||||
|
'customers_joined_1y',
|
||||||
|
]
|
||||||
|
|
||||||
|
const MODULE_ID_SET = new Set<string>(BUSINESS_MODULE_IDS)
|
||||||
|
const HOME_CHART_ID_SET = new Set<string>(HOME_CHART_IDS)
|
||||||
|
|
||||||
|
export function isBusinessModuleId(value: unknown): value is BusinessModuleId {
|
||||||
|
return typeof value === 'string' && MODULE_ID_SET.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHomeChartId(value: unknown): value is HomeChartId {
|
||||||
|
return typeof value === 'string' && HOME_CHART_ID_SET.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEnabledBusinessModules(value: unknown): BusinessModuleId[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...DEFAULT_ENABLED_BUSINESS_MODULES]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = new Set<BusinessModuleId>()
|
||||||
|
for (const item of value) {
|
||||||
|
if (isBusinessModuleId(item)) selected.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BUSINESS_MODULE_IDS.filter((id) => selected.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHomeCharts(value: unknown): [HomeChartId, HomeChartId] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...DEFAULT_HOME_CHARTS]
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = isHomeChartId(value[0]) ? value[0] : DEFAULT_HOME_CHARTS[0]
|
||||||
|
const second = isHomeChartId(value[1]) ? value[1] : DEFAULT_HOME_CHARTS[1]
|
||||||
|
return [first, second]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActiveHomeChart(value: HomeChartId): boolean {
|
||||||
|
return value !== 'none'
|
||||||
|
}
|
||||||
@@ -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: August 5, 2026
|
Last updated: August 7, 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -256,7 +256,11 @@ Products accept optional `brandId` on create/update.
|
|||||||
- `POST /tenants/:host/contact-submissions` — public website contact form
|
- `POST /tenants/:host/contact-submissions` — public website contact form
|
||||||
|
|
||||||
### Business settings
|
### Business settings
|
||||||
- `GET/PATCH /businesses/:businessId/settings` — includes `branding.primaryColor`
|
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`
|
||||||
|
- `modules.enabled`: optional CMS modules (`products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`); missing key → all enabled (legacy). Super-admin only for modules PATCH.
|
||||||
|
- `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`); `none` hides that slot; defaults orders + customers.
|
||||||
|
- Always-on (not in modules list): customers, website, profile, settings, home
|
||||||
|
- Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts`
|
||||||
|
|
||||||
### Portfolios
|
### Portfolios
|
||||||
- `GET/POST/PATCH/DELETE /businesses/:businessId/portfolios`
|
- `GET/POST/PATCH/DELETE /businesses/:businessId/portfolios`
|
||||||
@@ -437,6 +441,7 @@ Global input sizing lives in `packages/dashboard-core/src/styles/tokens.css` (cu
|
|||||||
|
|
||||||
- **Farsi text:** Customer app → Yekan Bakh (`src/fonts/yekanbakh.css`, `--font-fa` in `index.css`); business/super-admin → IranYekan (`iranyekan.css`, `.faText`). Input + placeholder must share the same stack — see `.cursor/rules/ui-farsi-fonts.mdc`
|
- **Farsi text:** Customer app → Yekan Bakh (`src/fonts/yekanbakh.css`, `--font-fa` in `index.css`); business/super-admin → IranYekan (`iranyekan.css`, `.faText`). Input + placeholder must share the same stack — see `.cursor/rules/ui-farsi-fonts.mdc`
|
||||||
- **Per-business theme:** Super Admin sets `branding.primaryColor`; business app applies via `BusinessThemeProvider`
|
- **Per-business theme:** Super Admin sets `branding.primaryColor`; business app applies via `BusinessThemeProvider`
|
||||||
|
- **Per-business modules:** Super Admin **Modules** column on Businesses list; stored in `settings.modules.enabled`; business Home + Sidebar hide disabled modules
|
||||||
- **RTL:** Farsi inputs use `dir="rtl"` and the app’s `--font-fa`
|
- **RTL:** Farsi inputs use `dir="rtl"` and the app’s `--font-fa`
|
||||||
- **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc`
|
- **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc`
|
||||||
- **Toasts:** see `.cursor/rules/ui-toasts.mdc`
|
- **Toasts:** see `.cursor/rules/ui-toasts.mdc`
|
||||||
|
|||||||
Reference in New Issue
Block a user