Add a Statistics page for storefront visits and instruction 1:1/3:2 crops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 11:10:52 +03:30
co-authored by Cursor
parent d9bdfbbb12
commit 6be867ea7a
11 changed files with 410 additions and 17 deletions
+2
View File
@@ -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() {
<Route element={<ProtectedRoute />}>
<Route element={<PageLayout />}>
<Route index element={<HomePage />} />
<Route path="statistics" element={<StatisticsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="business-profile" element={<BusinessProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
@@ -205,7 +205,8 @@
.gridHome,
.gridFour,
.gridCols4,
.gridCols5 {
.gridCols5,
.grid12 {
grid-template-columns: 1fr;
gap: 16px;
}
+8
View File
@@ -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<NavItem[]>(() => {
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',
+36 -2
View File
@@ -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<MessageKey, string> = {
'role.staff': 'کارمند',
'nav.home': 'خانه',
'nav.statistics': 'آمار',
'nav.businessProfile': 'پروفایل کسب‌وکار',
'sidebar.addLogo': 'افزودن لوگو',
'nav.products': 'محصولات',
@@ -2611,6 +2628,19 @@ const fa: Record<MessageKey, string> = {
'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<MessageKey, string> = {
'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<MessageKey, string> = {
'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<string, MessageKey> = {
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')] },
@@ -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<VideoProvider>('youtube')
const [embedCode, setEmbedCode] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null)
const [coverImageAspect, setCoverImageAspect] = useState<CoverImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([])
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(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() {
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4} ${styles.thumbnailField}`}>
<label>{t('instruction.form.coverImage')}</label>
<ImageCropper
value={coverImage}
onChange={setCoverImage}
aspect={3 / 2}
aspect={selectedAspect}
uploadLabel={t('instruction.form.uploadCoverImage')}
hint={t('instruction.form.cropHint')}
hint={t('instruction.form.cropHint', { ratio: coverImageAspect })}
changeLabel={t('instruction.form.changeCoverImage')}
/>
<div
className={styles.aspectSwitch}
role="radiogroup"
aria-label={t('instruction.form.aspectAria')}
>
{COVER_IMAGE_ASPECTS.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={coverImageAspect === option.id}
className={`${styles.aspectSwitchBtn} ${
coverImageAspect === option.id ? styles.aspectSwitchBtnSelected : ''
}`}
onClick={() => {
if (option.id === coverImageAspect) return
setCoverImageAspect(option.id)
if (coverImage) {
setCoverImage(null)
setFeaturedMediaId(null)
}
}}
disabled={isSubmitting}
>
{option.id}
</button>
))}
</div>
</div>
<div className={styles.formColumn}>
@@ -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 {
@@ -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;
}
}
+161
View File
@@ -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<Record<ViewSummaryGroupId, ViewPeriodCounts> | 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 (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('title.statistics') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="STATISTICS">{t('title.statistics')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('statistics.page.subtitle')}</p>
</div>
</div>
{visibleCharts.length > 0 ? (
<div className={pageStyles.grid12}>
{visibleCharts.map((chartId, index) => (
<div key={`${chartId}-${index}`} className={pageStyles.col6}>
<HomeChartSlot chartId={chartId} />
</div>
))}
</div>
) : null}
{error ? (
<div className={styles.error} role="alert">
{error}
</div>
) : null}
{visibleRows.map((row) => {
const counts = rows?.[row.id] ?? EMPTY_COUNTS
return (
<article key={row.id} className={styles.row}>
<div className={styles.rowName}>
<h3 className={styles.rowTitle}>{t(row.titleKey)}</h3>
{locale === 'fa' ? (
<span className={styles.rowTitleEn} lang="en" dir="ltr">
{translate('en', row.titleKey)}
</span>
) : null}
</div>
<div className={styles.chips}>
{METRICS.map((metric) => (
<div key={metric.id} className={styles.chip}>
<strong className={styles.chipValue} lang="en" dir="ltr">
{loading || !rows ? '—' : formatCount(counts[metric.id])}
</strong>
<span className={styles.chipLabel}>{t(metric.labelKey)}</span>
</div>
))}
</div>
</article>
)
})}
</main>
)
}
@@ -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<ViewSummaryGroupId, ViewPeriodCounts>
}
export async function getViewSummary(signal?: AbortSignal) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return apiRequest<ViewSummaryResponse>(
`/businesses/${businessId}/analytics/views/summary`,
{ auth: true, signal },
)
}
+3 -2
View File
@@ -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
@@ -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 {