diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 7187b5c..6108a95 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -10,6 +10,7 @@ import { ProtectedRoute } from './components/ProtectedRoute' import { GuestRoute } from './components/GuestRoute' import { PageLayout } from './components/PageLayout' import { HomePage } from './pages/HomePage' +import { StatisticsPage } from './pages/StatisticsPage' import { ProductsPage } from './pages/ProductsPage' import { CategoriesPage } from './pages/CategoriesPage' import { BrandsPage } from './pages/BrandsPage' @@ -93,6 +94,7 @@ function App() { }> }> } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/PageContent.module.css b/apps/business/src/components/PageContent.module.css index b0fb7aa..4fd5212 100644 --- a/apps/business/src/components/PageContent.module.css +++ b/apps/business/src/components/PageContent.module.css @@ -205,7 +205,8 @@ .gridHome, .gridFour, .gridCols4, - .gridCols5 { + .gridCols5, + .grid12 { grid-template-columns: 1fr; gap: 16px; } diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index 67a271c..4eb395a 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { Home, + BarChart3, ShoppingBag, Store, Users, @@ -110,6 +111,13 @@ export function Sidebar({ open = false }: SidebarProps) { const navItems = useMemo(() => { const items: NavItem[] = [ { type: 'link', id: 'home', icon: Home, labelKey: 'nav.home', to: '/' }, + { + type: 'link', + id: 'statistics', + icon: BarChart3, + labelKey: 'nav.statistics', + to: '/statistics', + }, { type: 'link', id: 'business-profile', diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 767e152..8c0b87d 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -16,6 +16,7 @@ const en = { 'role.staff': 'Staff', 'nav.home': 'Home', + 'nav.statistics': 'Statistics', 'nav.businessProfile': 'Business Profile', 'sidebar.addLogo': 'Add logo', 'nav.products': 'Products', @@ -296,6 +297,19 @@ const en = { 'home.chart.portfolioViews.error': 'Unable to load portfolio view activity.', 'home.chart.portfolioViews.bar': '{day}: {count} views', + 'statistics.page.subtitle': 'Website visits and activity for your storefront.', + 'statistics.errorLoad': 'Unable to load visit statistics.', + 'statistics.row.all': 'Total visit', + 'statistics.row.home': 'Home page', + 'statistics.row.products': 'Products', + 'statistics.row.blog': 'Blog', + 'statistics.row.portfolio': 'Portfolio', + 'statistics.row.videos': 'Videos', + 'statistics.metric.today': 'Today', + 'statistics.metric.week': 'This week', + 'statistics.metric.month': 'This month', + 'statistics.metric.total': 'Total', + 'products.overview.subtitle': 'Manage your products, inventory and categories.', 'products.card.list.desc': 'View, edit and manage all your existing products.', 'products.card.new.title': 'Add a New Product', @@ -1359,7 +1373,8 @@ const en = { 'instruction.form.coverImage': 'Cover Image', 'instruction.form.uploadCoverImage': 'Upload cover image', 'instruction.form.changeCoverImage': 'Change cover image', - 'instruction.form.cropHint': 'Click to select, then crop (3:2)', + 'instruction.form.cropHint': 'Click to select, then crop ({ratio})', + 'instruction.form.aspectAria': 'Cover image aspect ratio', 'instruction.form.provider': 'Video provider', 'instruction.form.category': 'Category', 'instruction.form.categoryPlaceholder': 'Search and select category...', @@ -1958,6 +1973,7 @@ const en = { 'title.signIn': 'Sign in', 'title.home': 'Home', + 'title.statistics': 'Statistics', 'title.profile': 'Profile', 'title.businessProfile': 'Business Profile', 'title.products': 'Products', @@ -2334,6 +2350,7 @@ const fa: Record = { 'role.staff': 'کارمند', 'nav.home': 'خانه', + 'nav.statistics': 'آمار', 'nav.businessProfile': 'پروفایل کسب‌وکار', 'sidebar.addLogo': 'افزودن لوگو', 'nav.products': 'محصولات', @@ -2611,6 +2628,19 @@ const fa: Record = { 'home.chart.portfolioViews.error': 'بارگذاری بازدید نمونه‌کارها ممکن نشد.', 'home.chart.portfolioViews.bar': '{day}: {count} بازدید', + 'statistics.page.subtitle': 'بازدید وب‌سایت و فعالیت فروشگاه شما.', + 'statistics.errorLoad': 'بارگذاری آمار بازدید ممکن نشد.', + 'statistics.row.all': 'بازدید کل', + 'statistics.row.home': 'صفحه اصلی', + 'statistics.row.products': 'محصولات', + 'statistics.row.blog': 'بلاگ', + 'statistics.row.portfolio': 'نمونه کار', + 'statistics.row.videos': 'ویدیوها', + 'statistics.metric.today': 'امروز', + 'statistics.metric.week': 'این هفته', + 'statistics.metric.month': 'این ماه', + 'statistics.metric.total': 'کل', + 'products.overview.subtitle': 'محصولات، موجودی و دسته‌بندی‌ها را مدیریت کنید.', 'products.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.', 'products.card.new.title': 'افزودن محصول جدید', @@ -3672,7 +3702,8 @@ const fa: Record = { 'instruction.form.coverImage': 'تصویر کاور', 'instruction.form.uploadCoverImage': 'بارگذاری تصویر کاور', 'instruction.form.changeCoverImage': 'تغییر تصویر کاور', - 'instruction.form.cropHint': 'برای انتخاب و برش (۳:۲) کلیک کنید', + 'instruction.form.cropHint': 'برای انتخاب و برش ({ratio}) کلیک کنید', + 'instruction.form.aspectAria': 'نسبت تصویر کاور', 'instruction.form.provider': 'ارائه‌دهنده ویدیو', 'instruction.form.category': 'دسته‌بندی', 'instruction.form.categoryPlaceholder': 'جستجو و انتخاب دسته‌بندی...', @@ -4273,6 +4304,7 @@ const fa: Record = { 'title.signIn': 'ورود', 'title.home': 'خانه', + 'title.statistics': 'آمار', 'title.profile': 'پروفایل', 'title.businessProfile': 'پروفایل کسب‌وکار', 'title.products': 'محصولات', @@ -4641,6 +4673,7 @@ export type BusinessMessageKey = MessageKey /** Maps hardcoded English breadcrumb labels used across pages → message keys. */ const BREADCRUMB_LABEL_KEYS: Record = { Home: 'nav.home', + Statistics: 'nav.statistics', Dashboard: 'bc.dashboard', 'Business Profile': 'nav.businessProfile', Products: 'nav.products', @@ -4726,6 +4759,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR const t = (key: MessageKey) => translate(locale, key) return [ { match: '/login', labels: [t('title.signIn')] }, + { match: '/statistics', labels: [t('title.statistics')] }, { match: '/profile', labels: [t('title.profile')] }, { match: '/business-profile', labels: [t('title.businessProfile')] }, { match: '/products/categories', labels: [t('title.products'), t('title.categories')] }, diff --git a/apps/business/src/pages/AddNewInstructionPage.tsx b/apps/business/src/pages/AddNewInstructionPage.tsx index 13804c8..a276f4f 100644 --- a/apps/business/src/pages/AddNewInstructionPage.tsx +++ b/apps/business/src/pages/AddNewInstructionPage.tsx @@ -29,6 +29,13 @@ import { flattenCategories } from '../utils/categories' import pageStyles from '../components/PageContent.module.css' import styles from './AddNewProductPage.module.css' +type CoverImageAspectId = '1:1' | '3:2' + +const COVER_IMAGE_ASPECTS: { id: CoverImageAspectId; aspect: number }[] = [ + { id: '1:1', aspect: 1 }, + { id: '3:2', aspect: 3 / 2 }, +] + export function AddNewInstructionPage() { const { id } = useParams() const navigate = useNavigate() @@ -45,6 +52,7 @@ export function AddNewInstructionPage() { const [videoProvider, setInstructionProvider] = useState('youtube') const [embedCode, setEmbedCode] = useState('') const [coverImage, setCoverImage] = useState(null) + const [coverImageAspect, setCoverImageAspect] = useState('3:2') const [tags, setTags] = useState([]) const [featuredMediaId, setFeaturedMediaId] = useState(null) const [loaded, setLoaded] = useState(!isEdit) @@ -52,6 +60,8 @@ export function AddNewInstructionPage() { const [error, setError] = useState('') const categoryOptions = flattenCategories(categories) + const selectedAspect = + COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2 useEffect(() => { const controller = new AbortController() @@ -197,16 +207,44 @@ export function AddNewInstructionPage() {
-
+
+
+ {COVER_IMAGE_ASPECTS.map((option) => ( + + ))} +
diff --git a/apps/business/src/pages/AddNewProductPage.module.css b/apps/business/src/pages/AddNewProductPage.module.css index ba21292..33d8e45 100644 --- a/apps/business/src/pages/AddNewProductPage.module.css +++ b/apps/business/src/pages/AddNewProductPage.module.css @@ -289,26 +289,32 @@ margin-top: 4px; padding: 3px; gap: 2px; - background: rgba(148, 163, 184, 0.14); - border: 1px solid rgba(148, 163, 184, 0.28); + background: transparent; + border: 1px solid #0a1628; border-radius: 50px; } .aspectSwitchBtn { + display: inline-flex; + align-items: center; + justify-content: center; min-width: 52px; padding: 6px 12px; font-size: 12px; font-weight: 600; line-height: 1.2; font-family: var(--font-en), var(--font-ui); - color: var(--text-secondary); + color: var(--primary); background: transparent; border-radius: 50px; + text-align: center; + direction: ltr; transition: background 0.15s, color 0.15s, box-shadow 0.15s; } .aspectSwitchBtn:hover:not(:disabled) { color: var(--primary); + background: rgba(var(--primary-rgb) / 0.1); } .aspectSwitchBtn:disabled { @@ -324,6 +330,7 @@ .aspectSwitchBtnSelected:hover:not(:disabled) { color: #fff; + background: var(--primary); } .videoEmbedRow { diff --git a/apps/business/src/pages/StatisticsPage.module.css b/apps/business/src/pages/StatisticsPage.module.css new file mode 100644 index 0000000..e049385 --- /dev/null +++ b/apps/business/src/pages/StatisticsPage.module.css @@ -0,0 +1,110 @@ +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px 24px; + margin-top: 12px; + padding: 14px 18px; + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--glass-shadow); +} + +.row:first-of-type { + margin-top: 24px; +} + +.rowTitle { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.rowName { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + flex: 1 1 auto; + min-width: 0; +} + +.rowTitleEn { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-muted); + font-family: var(--font-en), var(--font-ui); + line-height: 1.2; +} + +.chips { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + flex: 0 0 auto; +} + +.chip { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 76px; + padding: 8px 14px; + border-radius: 16px; + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.chipValue { + font-family: var(--font-en), var(--font-ui); + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1; + color: inherit; +} + +.chipLabel { + font-size: 11px; + font-weight: 500; + line-height: 1.2; + color: inherit; + opacity: 0.85; + white-space: nowrap; +} + +.error { + margin-top: 20px; + padding: 12px 16px; + font-size: 14px; + color: #b91c1c; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-sm); +} + +.error + .row { + margin-top: 16px; +} + +@media (max-width: 768px) { + .row { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .chips { + justify-content: flex-start; + } +} diff --git a/apps/business/src/pages/StatisticsPage.tsx b/apps/business/src/pages/StatisticsPage.tsx new file mode 100644 index 0000000..0317967 --- /dev/null +++ b/apps/business/src/pages/StatisticsPage.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from 'react' +import { Breadcrumbs } from '../components/Breadcrumbs' +import { HomeChartSlot } from '../components/HomeChartSlot' +import { PageTitle } from '../components/PageTitle' +import { useTenantBranding } from '../context/TenantBrandingContext' +import { useLocale } from '@meshkee/dashboard-ui' +import { useT } from '../i18n/useT' +import { translate, type BusinessMessageKey } from '../i18n/messages' +import { ApiError } from '../lib/api' +import { + getViewSummary, + type ViewPeriodCounts, + type ViewSummaryGroupId, +} from '../services/dailyActivityService' +import { + hasAnyBusinessModule, + hasBusinessModule, + isActiveHomeChart, + type BusinessModuleId, +} from '../utils/businessModules' +import pageStyles from '../components/PageContent.module.css' +import styles from './StatisticsPage.module.css' + +const EMPTY_COUNTS: ViewPeriodCounts = { + today: 0, + week: 0, + month: 0, + total: 0, +} + +const METRICS: { + id: keyof ViewPeriodCounts + labelKey: BusinessMessageKey +}[] = [ + { id: 'today', labelKey: 'statistics.metric.today' }, + { id: 'week', labelKey: 'statistics.metric.week' }, + { id: 'month', labelKey: 'statistics.metric.month' }, + { id: 'total', labelKey: 'statistics.metric.total' }, +] + +const STAT_ROWS: { + id: ViewSummaryGroupId + titleKey: BusinessMessageKey + moduleId?: BusinessModuleId + anyModuleIds?: BusinessModuleId[] +}[] = [ + { id: 'all', titleKey: 'statistics.row.all' }, + { id: 'home', titleKey: 'statistics.row.home' }, + { + id: 'products', + titleKey: 'statistics.row.products', + anyModuleIds: ['products', 'store'], + }, + { id: 'blog', titleKey: 'statistics.row.blog', moduleId: 'blog' }, + { id: 'portfolio', titleKey: 'statistics.row.portfolio', moduleId: 'portfolio' }, + { id: 'videos', titleKey: 'statistics.row.videos', moduleId: 'videos' }, +] + +function formatCount(value: number) { + return new Intl.NumberFormat('en-US').format(value) +} + +export function StatisticsPage() { + const t = useT() + const { locale } = useLocale() + const { enabledModules, homeCharts } = useTenantBranding() + const [rows, setRows] = useState | null>( + null, + ) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + const visibleCharts = homeCharts.filter(isActiveHomeChart) + const visibleRows = STAT_ROWS.filter((row) => { + if (row.anyModuleIds) { + return hasAnyBusinessModule(enabledModules, row.anyModuleIds) + } + return !row.moduleId || hasBusinessModule(enabledModules, row.moduleId) + }) + + useEffect(() => { + const controller = new AbortController() + + async function load() { + setLoading(true) + setError('') + try { + const data = await getViewSummary(controller.signal) + setRows(data.rows) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + setError(err instanceof ApiError ? err.message : t('statistics.errorLoad')) + } finally { + if (!controller.signal.aborted) setLoading(false) + } + } + + void load() + return () => controller.abort() + }, [t]) + + return ( +
+ + +
+
+ {t('title.statistics')} +

{t('statistics.page.subtitle')}

+
+
+ + {visibleCharts.length > 0 ? ( +
+ {visibleCharts.map((chartId, index) => ( +
+ +
+ ))} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {visibleRows.map((row) => { + const counts = rows?.[row.id] ?? EMPTY_COUNTS + return ( +
+
+

{t(row.titleKey)}

+ {locale === 'fa' ? ( + + {translate('en', row.titleKey)} + + ) : null} +
+
+ {METRICS.map((metric) => ( +
+ + {loading || !rows ? '—' : formatCount(counts[metric.id])} + + {t(metric.labelKey)} +
+ ))} +
+
+ ) + })} +
+ ) +} diff --git a/apps/business/src/services/dailyActivityService.ts b/apps/business/src/services/dailyActivityService.ts index 75ad186..d905442 100644 --- a/apps/business/src/services/dailyActivityService.ts +++ b/apps/business/src/services/dailyActivityService.ts @@ -73,3 +73,33 @@ export async function getViewDailyActivity( { auth: true, signal }, ) } + +export type ViewSummaryGroupId = + | 'all' + | 'home' + | 'products' + | 'blog' + | 'portfolio' + | 'videos' + +export interface ViewPeriodCounts { + today: number + week: number + month: number + total: number +} + +export interface ViewSummaryResponse { + rows: Record +} + +export async function getViewSummary(signal?: AbortSignal) { + const businessId = getActiveBusinessId() + if (!businessId) { + throw new Error('No active business selected. Please sign in again.') + } + return apiRequest( + `/businesses/${businessId}/analytics/views/summary`, + { auth: true, signal }, + ) +} diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index d93c126..a0246a8 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 31, 2026 +Last updated: September 1, 2026 --- @@ -278,7 +278,8 @@ Products accept optional `brandId` on create/update. - `website.specialProductsSource`: `product` \| `store_item`. Unset → `store_item` if the store module is enabled, otherwise `product`. - `modules.enabled`: optional modules — business: `products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`, `finance`; customer: `customer_products`. Missing key → all business modules enabled (legacy); customer modules stay opt-in. Super-admin only for modules PATCH. - `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`, `product_views_30d`, `website_views_30d`, `portfolio_views_30d`); `none` hides that slot; defaults orders + customers. View charts read `website_page_views` (auto-counted on public list/detail GETs; ~6 month retention). `website_views_30d` aggregates **all** page kinds (`kind=all`); product/portfolio/blog charts filter by detail kind. Home-only hits also increment on `GET .../website/sliders` and `GET .../website/static-images?pageKey=home`. Lifetime totals in `website_page_view_counters`. -- Always-on (not in modules list): business — customers, website, profile, settings, home; customer — home, profile, addresses, orders, favorites +- **Statistics** (`/statistics`, always-on sidebar): same two home chart slots, then visit KPI rows (today / this week Sat–Fri / this month / lifetime total) for all pages, home, products (+ store items), blog, portfolio, videos — module-gated except total + home. API: `GET businesses/:id/analytics/views/summary`. +- Always-on (not in modules list): business — home, **statistics**, customers, website, profile, settings; customer — home, profile, addresses, orders, favorites - Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts` + `specialProductsSource` ### Portfolios diff --git a/packages/dashboard-ui/src/components/EmptyState.module.css b/packages/dashboard-ui/src/components/EmptyState.module.css index faf3463..96a85fe 100644 --- a/packages/dashboard-ui/src/components/EmptyState.module.css +++ b/packages/dashboard-ui/src/components/EmptyState.module.css @@ -69,9 +69,9 @@ background-color: transparent; border-width: 2px; border-style: dashed; - border-color: #64748b; + border-color: #0a1628; border-radius: 12px; - color: var(--primary, #0a1628); + color: var(--primary); font-family: var(--font-ui); text-decoration: none; cursor: pointer; @@ -89,18 +89,19 @@ button.addBox { background-color: transparent; border-width: 2px; border-style: dashed; - border-color: #64748b; + border-color: #0a1628; + color: var(--primary); } .addBox:hover, a.addBox:hover, button.addBox:hover { - background-color: rgba(10, 22, 40, 0.07); + background-color: rgba(var(--primary-rgb) / 0.08); border-style: dashed; border-color: #0a1628; - color: #0a1628; + color: var(--primary); transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.1); + box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18); } .addBox:focus-visible {