diff --git a/apps/business/src/components/HomeChartSlot.tsx b/apps/business/src/components/HomeChartSlot.tsx new file mode 100644 index 0000000..e23853d --- /dev/null +++ b/apps/business/src/components/HomeChartSlot.tsx @@ -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 ( +
+
+
+

{t(titleKey)}

+

{t(subtitleKey)}

+
+
+

{t('home.chart.placeholder.comingSoon')}

+
+ ) +} + +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 ( + + ) + case 'customers_joined_1y': + return ( + + ) + case 'products_added_1y': + return + case 'blog_views_30d': + return + default: + return null + } +} diff --git a/apps/business/src/components/PageContent.module.css b/apps/business/src/components/PageContent.module.css index 01d6a9d..60a832f 100644 --- a/apps/business/src/components/PageContent.module.css +++ b/apps/business/src/components/PageContent.module.css @@ -87,6 +87,10 @@ grid-column: span 6; } +.col12 { + grid-column: span 12; +} + @media (max-width: 1536px) { .gridHome { grid-template-columns: repeat(4, 1fr); diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index 5f49992..1001e7d 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -26,6 +26,11 @@ import { } from '../lib/businessContext' import { isAbortError } from '../lib/api' import { getBusinessProfile } from '../services/businessProfileService' +import { useTenantBranding } from '../context/TenantBrandingContext' +import { + hasBusinessModule, + type BusinessModuleId, +} from '../utils/businessModules' import { Tooltip } from './Tooltip' import styles from './Sidebar.module.css' @@ -41,6 +46,7 @@ interface NavGroup { labelKey: BusinessMessageKey basePath: string children: NavChild[] + moduleId?: BusinessModuleId } interface NavLinkItem { @@ -49,6 +55,7 @@ interface NavLinkItem { icon: LucideIcon labelKey: BusinessMessageKey to: string + moduleId?: BusinessModuleId } type NavItem = NavLinkItem | NavGroup @@ -62,6 +69,7 @@ export function Sidebar() { const navigate = useNavigate() const { user, logout } = useAuth() const { locale } = useLocale() + const { enabledModules } = useTenantBranding() const t = useT() const [openGroups, setOpenGroups] = useState>({}) const [brandLogoUrl, setBrandLogoUrl] = useState(null) @@ -79,96 +87,104 @@ export function Sidebar() { }, [locale, nameEn, nameFa, fallbackBusinessName]) const navItems = useMemo( - () => [ - { 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: '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' }, - ], - }, - { type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' }, - ], - [], + () => + [ + { 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', + moduleId: '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', + moduleId: '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: 'group', + id: 'blog', + icon: FileText, + labelKey: 'nav.blog', + basePath: '/blog', + moduleId: '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', + moduleId: 'portfolio', + 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' }, + ], + }, + { type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' }, + ].filter( + (item) => + !item.moduleId || hasBusinessModule(enabledModules, item.moduleId), + ), + [enabledModules], ) useEffect(() => { diff --git a/apps/business/src/context/TenantBrandingContext.tsx b/apps/business/src/context/TenantBrandingContext.tsx index ac4ddd5..938d7e4 100644 --- a/apps/business/src/context/TenantBrandingContext.tsx +++ b/apps/business/src/context/TenantBrandingContext.tsx @@ -15,12 +15,22 @@ import { getBusinessDomain } from '../lib/config' import { BUSINESS_PROFILE_UPDATED_EVENT } from '../lib/businessContext' import { getBusinessProfile } from '../services/businessProfileService' import { resolveTenantByDomain } from '../services/tenantService' +import { + DEFAULT_ENABLED_BUSINESS_MODULES, + DEFAULT_HOME_CHARTS, + normalizeEnabledBusinessModules, + normalizeHomeCharts, + type BusinessModuleId, + type HomeChartId, +} from '../utils/businessModules' interface TenantBrandingContextValue { businessName: string businessNameEn: string logoUrl: string | null faviconUrl: string | null + enabledModules: BusinessModuleId[] + homeCharts: [HomeChartId, HomeChartId] refreshBranding: () => void } @@ -42,6 +52,12 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) { const [nameFa, setNameFa] = useState('') const [logoUrl, setLogoUrl] = useState(null) const [faviconUrl, setFaviconUrl] = useState(null) + const [enabledModules, setEnabledModules] = useState( + DEFAULT_ENABLED_BUSINESS_MODULES, + ) + const [homeCharts, setHomeCharts] = useState<[HomeChartId, HomeChartId]>([ + ...DEFAULT_HOME_CHARTS, + ]) const [refreshToken, setRefreshToken] = useState(0) const defaultLocaleAppliedRef = useRef(false) @@ -95,6 +111,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) { setNameFa(nextNameFa || nextNameEn || domain) setLogoUrl(nextLogo) setFaviconUrl(nextFavicon) + setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules)) + setHomeCharts(normalizeHomeCharts(tenant.homeCharts)) applyDocumentFavicon(nextFavicon) if (!defaultLocaleAppliedRef.current) { defaultLocaleAppliedRef.current = true @@ -112,6 +130,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) { setNameFa(domain) setLogoUrl(null) setFaviconUrl(null) + setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES]) + setHomeCharts([...DEFAULT_HOME_CHARTS]) applyDocumentFavicon(null) } } @@ -142,8 +162,24 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) { ) 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 ( diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 88ae180..54f2cb6 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -192,6 +192,19 @@ const en = { 'home.chart.customers.error': 'Unable to load customer activity.', 'home.chart.customers.bar': '{day}: {count} registered', '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.card.list.desc': 'View, edit and manage all your existing products.', @@ -1231,7 +1244,7 @@ const fa: Record = { 'home.card.website.link': 'مشاهده وب‌سایت', 'home.chart.orders.title': 'سفارش‌ها', - 'home.chart.orders.subtitle': 'سفارش‌ها و افزودن به سبد در ۳۰ روز گذشته', + 'home.chart.orders.subtitle': 'سفارش‌ها در ۳۰ روز گذشته', 'home.chart.orders.legend': 'سفارش ({count})', 'home.chart.orders.cartLegend': 'افزودن به سبد ({count})', 'home.chart.orders.loading': 'در حال بارگذاری نمودار...', @@ -1246,6 +1259,19 @@ const fa: Record = { 'home.chart.customers.error': 'بارگذاری فعالیت مشتریان ممکن نشد.', 'home.chart.customers.bar': '{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.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.', diff --git a/apps/business/src/pages/HomePage.tsx b/apps/business/src/pages/HomePage.tsx index 4d3c443..2f82ef6 100644 --- a/apps/business/src/pages/HomePage.tsx +++ b/apps/business/src/pages/HomePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { CalendarDays } from 'lucide-react' import { ShoppingBag, @@ -11,7 +11,7 @@ import { import { useLocale } from '@meshkee/dashboard-ui' import { useAuth } from '../context/AuthContext' import { SectionCard } from '../components/SectionCard' -import { DailyActivityChart } from '../components/DailyActivityChart' +import { HomeChartSlot } from '../components/HomeChartSlot' import { useT } from '../i18n/useT' import type { BusinessMessageKey } from '../i18n/messages' import { listProducts } from '../services/productService' @@ -19,10 +19,12 @@ import { listStoreItems } from '../services/storeItemService' import { listCustomers } from '../services/customerService' import { listBlogs } from '../services/blogService' import { listPortfolios } from '../services/portfolioService' +import { useTenantBranding } from '../context/TenantBrandingContext' import { - getCustomersDailyActivity, - getOrdersDailyActivity, -} from '../services/dailyActivityService' + hasBusinessModule, + isActiveHomeChart, + type BusinessModuleId, +} from '../utils/businessModules' import styles from '../components/PageContent.module.css' type CountKey = 'products' | 'store' | 'customers' | 'blog' | 'portfolios' @@ -35,6 +37,8 @@ const sections: { countLabelKey?: BusinessMessageKey href: string countKey?: CountKey + /** When set, card is shown only if this optional module is enabled. */ + moduleId?: BusinessModuleId }[] = [ { icon: ShoppingBag, @@ -44,6 +48,7 @@ const sections: { countLabelKey: 'home.card.products.count', href: '/products', countKey: 'products', + moduleId: 'products', }, { icon: Store, @@ -53,6 +58,7 @@ const sections: { countLabelKey: 'home.card.store.count', href: '/store', countKey: 'store', + moduleId: 'store', }, { icon: Users, @@ -71,6 +77,7 @@ const sections: { countLabelKey: 'home.card.blog.count', href: '/blog', countKey: 'blog', + moduleId: 'blog', }, { icon: Briefcase, @@ -80,6 +87,7 @@ const sections: { countLabelKey: 'home.card.portfolios.count', href: '/portfolios', countKey: 'portfolios', + moduleId: 'portfolio', }, { icon: Globe, @@ -113,6 +121,7 @@ async function loadSectionCounts(signal: AbortSignal): Promise { export function HomePage() { const { user } = useAuth() const { locale } = useLocale() + const { enabledModules, homeCharts } = useTenantBranding() const t = useT() const [counts, setCounts] = useState({}) @@ -124,14 +133,11 @@ export function HomePage() { return () => controller.abort() }, []) - const loadOrdersActivity = useCallback( - (signal: AbortSignal) => getOrdersDailyActivity(30, signal), - [], - ) - const loadCustomersActivity = useCallback( - (signal: AbortSignal) => getCustomersDailyActivity(30, signal), - [], + const visibleSections = sections.filter( + (section) => + !section.moduleId || hasBusinessModule(enabledModules, section.moduleId), ) + const visibleCharts = homeCharts.filter(isActiveHomeChart) const firstName = (locale === 'en' @@ -160,7 +166,7 @@ export function HomePage() {
- {sections.map((section) => ( + {visibleSections.map((section) => ( -
-
- + {visibleCharts.length > 0 ? ( +
+ {visibleCharts.map((chartId, index) => ( +
+ +
+ ))}
-
- -
-
+ ) : null} ) } diff --git a/apps/business/src/services/settingsService.ts b/apps/business/src/services/settingsService.ts index 1f707cb..3d94bad 100644 --- a/apps/business/src/services/settingsService.ts +++ b/apps/business/src/services/settingsService.ts @@ -25,10 +25,15 @@ export interface StoreSettings { orderProcessSteps: OrderProcessStep[] } +export interface ModulesSettings { + enabled: string[] +} + export interface BusinessSettings { branding: BrandingSettings dashboard: DashboardSettings store: StoreSettings + modules?: ModulesSettings } export interface SettingsResponse { diff --git a/apps/business/src/services/tenantService.ts b/apps/business/src/services/tenantService.ts index b54e997..a641bdc 100644 --- a/apps/business/src/services/tenantService.ts +++ b/apps/business/src/services/tenantService.ts @@ -1,6 +1,7 @@ import { apiRequest } from '../lib/api' import type { DashboardLocale } from '@meshkee/dashboard-core' import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors' +import type { BusinessModuleId, HomeChartId } from '../utils/businessModules' export interface ResolvedTenant { id: string @@ -10,6 +11,8 @@ export interface ResolvedTenant { domain: string primaryColor: BusinessPrimaryColorId defaultLocale?: DashboardLocale + enabledModules?: BusinessModuleId[] + homeCharts?: [HomeChartId, HomeChartId] logoUrl?: string | null faviconUrl?: string | null } diff --git a/apps/business/src/utils/businessModules.ts b/apps/business/src/utils/businessModules.ts new file mode 100644 index 0000000..e69f3ee --- /dev/null +++ b/apps/business/src/utils/businessModules.ts @@ -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(BUSINESS_MODULE_IDS) +const HOME_CHART_ID_SET = new Set(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() + 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' +} diff --git a/apps/super-admin/src/pages/BusinessesPage.module.css b/apps/super-admin/src/pages/BusinessesPage.module.css index 4cea5f3..aca8c68 100644 --- a/apps/super-admin/src/pages/BusinessesPage.module.css +++ b/apps/super-admin/src/pages/BusinessesPage.module.css @@ -414,6 +414,94 @@ 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 { min-height: var(--field-height); padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx index c7a88e2..1d6384c 100644 --- a/apps/super-admin/src/pages/BusinessesPage.tsx +++ b/apps/super-admin/src/pages/BusinessesPage.tsx @@ -55,9 +55,22 @@ import { getBusinessSettings, updateBusinessBranding, updateBusinessDefaultLocale, + updateBusinessModules, updateBusinessPrimaryColor, type DashboardLocale, } 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 { Pagination } from '@meshkee/dashboard-ui' import pageStyles from '../components/PageContent.module.css' @@ -70,6 +83,15 @@ function normalizeDefaultLocale(value: unknown): DashboardLocale { return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE } +function emptyModuleSelection( + enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES, +): Record { + const selected = new Set(enabled) + return Object.fromEntries( + BUSINESS_MODULE_IDS.map((id) => [id, selected.has(id)]), + ) as Record +} + function formatDate(value: string) { const d = new Date(value) if (Number.isNaN(d.getTime())) return value @@ -227,6 +249,17 @@ export function BusinessesPage() { const [savingColorId, setSavingColorId] = useState(null) const [savingLocaleId, setSavingLocaleId] = useState(null) + const [modulesOpen, setModulesOpen] = useState(false) + const [modulesBusiness, setModulesBusiness] = useState(null) + const [modulesSelected, setModulesSelected] = useState>( + () => 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 [migrateBusiness, setMigrateBusiness] = useState(null) 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() { if (!editBusiness) return setEditSubmitting(true) @@ -903,13 +992,14 @@ export function BusinessesPage() { Owner Theme Lang + Modules Actions {loading && ( - + Loading... @@ -917,7 +1007,7 @@ export function BusinessesPage() { {!loading && data?.items?.length === 0 && ( - + No results found. @@ -992,6 +1082,21 @@ export function BusinessesPage() { + + +
@@ -1212,6 +1317,104 @@ export function BusinessesPage() {
+ + {modulesError ? ( +

+ {modulesError} +

+ ) : null} +

+ Choose which optional modules this business can use. Customers, website, + and other general sections stay available for every business. +

+
+ {BUSINESS_MODULE_IDS.map((id) => ( + + ))} +
+
+

Home charts

+
+ + +
+
+ + +
+
+
+
+ + +
+ + } + modules: ModulesSettings } export interface BusinessSettingsResponse { @@ -68,3 +75,17 @@ export async function updateBusinessBranding( body: { branding }, }) } + +export async function updateBusinessModules( + businessId: string, + enabled: BusinessModuleId[], + charts: [HomeChartId, HomeChartId], +) { + return apiRequest(`/businesses/${businessId}/settings`, { + method: 'PATCH', + auth: true, + body: { + modules: { enabled, charts }, + }, + }) +} diff --git a/apps/super-admin/src/types/business.ts b/apps/super-admin/src/types/business.ts index f4922d3..0f34304 100644 --- a/apps/super-admin/src/types/business.ts +++ b/apps/super-admin/src/types/business.ts @@ -1,4 +1,5 @@ import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors' +import type { BusinessModuleId, HomeChartId } from '../utils/businessModules' import type { DashboardLocale } from '../services/businessSettingsService' export interface BusinessOwnerInfo { @@ -24,6 +25,9 @@ export interface BusinessListItem { isActive: boolean primaryColor: BusinessPrimaryColorId defaultLocale: DashboardLocale + enabledModules: BusinessModuleId[] + moduleCount: number + homeCharts: [HomeChartId, HomeChartId] oldBusinessId: string | null } diff --git a/apps/super-admin/src/utils/businessModules.ts b/apps/super-admin/src/utils/businessModules.ts new file mode 100644 index 0000000..cfb2f51 --- /dev/null +++ b/apps/super-admin/src/utils/businessModules.ts @@ -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 = { + 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 = { + 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(BUSINESS_MODULE_IDS) +const HOME_CHART_ID_SET = new Set(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() + 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' +} diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index e7e58f0..247037c 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -3,7 +3,7 @@ > **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. -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 ### 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 - `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` - **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` - **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc` - **Toasts:** see `.cursor/rules/ui-toasts.mdc`