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:
co-authored by
Cursor
parent
d9bdfbbb12
commit
6be867ea7a
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user