Add view traffic charts to business home and super-admin.

Wire product, website, portfolio, and blog 30-day view charts to the analytics API and expose the new chart types in super-admin business settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 01:12:45 +03:30
co-authored by Cursor
parent 468bb87fcf
commit 202ac7d4bd
7 changed files with 188 additions and 55 deletions
@@ -25,6 +25,8 @@ interface DailyActivityChartProps {
load: (signal: AbortSignal) => Promise<DualDailyActivityResponse> load: (signal: AbortSignal) => Promise<DualDailyActivityResponse>
/** Day bars (default) or roll up into last 12 months. */ /** Day bars (default) or roll up into last 12 months. */
granularity?: 'day' | 'month' granularity?: 'day' | 'month'
/** Hide the secondary series (for single-metric view charts). */
showSecondary?: boolean
} }
function formatDayLabel(dateKey: string, locale: string): string { function formatDayLabel(dateKey: string, locale: string): string {
@@ -47,6 +49,7 @@ export function DailyActivityChart({
secondaryBarTitleKey, secondaryBarTitleKey,
load, load,
granularity = 'day', granularity = 'day',
showSecondary = true,
}: DailyActivityChartProps) { }: DailyActivityChartProps) {
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
@@ -120,10 +123,12 @@ export function DailyActivityChart({
<span className={`${styles.legendDot} ${styles.tonePrimary}`} /> <span className={`${styles.legendDot} ${styles.tonePrimary}`} />
{t(primaryLegendKey, { count: primaryTotal })} {t(primaryLegendKey, { count: primaryTotal })}
</span> </span>
<span className={styles.legendItem}> {showSecondary ? (
<span className={`${styles.legendDot} ${styles.toneAccent}`} /> <span className={styles.legendItem}>
{t(secondaryLegendKey, { count: secondaryTotal })} <span className={`${styles.legendDot} ${styles.toneAccent}`} />
</span> {t(secondaryLegendKey, { count: secondaryTotal })}
</span>
) : null}
</div> </div>
</div> </div>
@@ -159,17 +164,19 @@ export function DailyActivityChart({
count: item.primary, count: item.primary,
})} })}
/> />
<div {showSecondary ? (
className={`${styles.bar} ${styles.barAccent} ${styles.barMonth}`} <div
style={{ className={`${styles.bar} ${styles.barAccent} ${styles.barMonth}`}
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0), style={{
}} height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
title={t(secondaryBarTitleKey, { }}
day: item.label, title={t(secondaryBarTitleKey, {
month: item.label, day: item.label,
count: item.secondary, month: item.label,
})} count: item.secondary,
/> })}
/>
) : null}
</div> </div>
<span <span
className={styles.label} className={styles.label}
@@ -196,16 +203,18 @@ export function DailyActivityChart({
style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }} style={{ height: Math.max(primaryHeight, item.count > 0 ? 4 : 0) }}
title={t(primaryBarTitleKey, { day: label, count: item.count })} title={t(primaryBarTitleKey, { day: label, count: item.count })}
/> />
<div {showSecondary ? (
className={`${styles.bar} ${styles.barAccent}`} <div
style={{ className={`${styles.bar} ${styles.barAccent}`}
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0), style={{
}} height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
title={t(secondaryBarTitleKey, { }}
day: label, title={t(secondaryBarTitleKey, {
count: secondaryCount, day: label,
})} count: secondaryCount,
/> })}
/>
) : null}
</div> </div>
<span className={styles.label} lang="en" dir="ltr"> <span className={styles.label} lang="en" dir="ltr">
{label} {label}
+82 -22
View File
@@ -4,36 +4,93 @@ import { ProductActivityChart } from './ProductActivityChart'
import { import {
getCustomersDailyActivity, getCustomersDailyActivity,
getOrdersDailyActivity, getOrdersDailyActivity,
getViewDailyActivity,
type WebsiteViewKind,
} from '../services/dailyActivityService' } from '../services/dailyActivityService'
import type { HomeChartId } from '../utils/businessModules' import type { HomeChartId } from '../utils/businessModules'
import { useT } from '../i18n/useT' import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css'
interface HomeChartSlotProps { interface HomeChartSlotProps {
chartId: HomeChartId chartId: HomeChartId
} }
function HomeChartPlaceholder({ chartId }: { chartId: HomeChartId }) { const VIEW_CHART_CONFIG: Record<
const t = useT() Extract<
const titleKey = HomeChartId,
chartId === 'blog_views_30d' 'blog_views_30d' | 'product_views_30d' | 'website_views_30d' | 'portfolio_views_30d'
? 'home.chart.blogViews.title' >,
: 'home.chart.placeholder.title' {
const subtitleKey = kind: WebsiteViewKind
chartId === 'blog_views_30d' titleKey: BusinessMessageKey
? 'home.chart.blogViews.subtitle' subtitleKey: BusinessMessageKey
: 'home.chart.placeholder.subtitle' legendKey: BusinessMessageKey
loadingKey: BusinessMessageKey
errorKey: BusinessMessageKey
barKey: BusinessMessageKey
}
> = {
product_views_30d: {
kind: 'product',
titleKey: 'home.chart.productViews.title',
subtitleKey: 'home.chart.productViews.subtitle',
legendKey: 'home.chart.productViews.legend',
loadingKey: 'home.chart.productViews.loading',
errorKey: 'home.chart.productViews.error',
barKey: 'home.chart.productViews.bar',
},
website_views_30d: {
kind: 'website',
titleKey: 'home.chart.websiteViews.title',
subtitleKey: 'home.chart.websiteViews.subtitle',
legendKey: 'home.chart.websiteViews.legend',
loadingKey: 'home.chart.websiteViews.loading',
errorKey: 'home.chart.websiteViews.error',
barKey: 'home.chart.websiteViews.bar',
},
portfolio_views_30d: {
kind: 'portfolio',
titleKey: 'home.chart.portfolioViews.title',
subtitleKey: 'home.chart.portfolioViews.subtitle',
legendKey: 'home.chart.portfolioViews.legend',
loadingKey: 'home.chart.portfolioViews.loading',
errorKey: 'home.chart.portfolioViews.error',
barKey: 'home.chart.portfolioViews.bar',
},
blog_views_30d: {
kind: 'blog',
titleKey: 'home.chart.blogViews.title',
subtitleKey: 'home.chart.blogViews.subtitle',
legendKey: 'home.chart.blogViews.legend',
loadingKey: 'home.chart.blogViews.loading',
errorKey: 'home.chart.blogViews.error',
barKey: 'home.chart.blogViews.bar',
},
}
function ViewActivityChart({
chartId,
}: {
chartId: keyof typeof VIEW_CHART_CONFIG
}) {
const config = VIEW_CHART_CONFIG[chartId]
const load = useCallback(
(signal: AbortSignal) => getViewDailyActivity(config.kind, 30, signal),
[config.kind],
)
return ( return (
<section className={styles.card} aria-label={t(titleKey)}> <DailyActivityChart
<div className={styles.header}> showSecondary={false}
<div> titleKey={config.titleKey}
<h3 className={styles.title}>{t(titleKey)}</h3> subtitleKey={config.subtitleKey}
<p className={styles.subtitle}>{t(subtitleKey)}</p> primaryLegendKey={config.legendKey}
</div> secondaryLegendKey={config.legendKey}
</div> loadingKey={config.loadingKey}
<p className={styles.status}>{t('home.chart.placeholder.comingSoon')}</p> errorKey={config.errorKey}
</section> primaryBarTitleKey={config.barKey}
secondaryBarTitleKey={config.barKey}
load={load}
/>
) )
} }
@@ -81,8 +138,11 @@ export function HomeChartSlot({ chartId }: HomeChartSlotProps) {
) )
case 'products_added_1y': case 'products_added_1y':
return <ProductActivityChart /> return <ProductActivityChart />
case 'product_views_30d':
case 'website_views_30d':
case 'portfolio_views_30d':
case 'blog_views_30d': case 'blog_views_30d':
return <HomeChartPlaceholder chartId={chartId} /> return <ViewActivityChart chartId={chartId} />
default: default:
return null return null
} }
+44 -6
View File
@@ -270,9 +270,28 @@ const en = {
'home.chart.customersJoined.activeBar': '{month}: {count} active', 'home.chart.customersJoined.activeBar': '{month}: {count} active',
'home.chart.blogViews.title': 'Article views', 'home.chart.blogViews.title': 'Article views',
'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days', 'home.chart.blogViews.subtitle': 'Blog article views in the last 30 days',
'home.chart.placeholder.title': 'Chart', 'home.chart.blogViews.legend': 'Views ({count})',
'home.chart.placeholder.subtitle': 'This chart will appear here once available.', 'home.chart.blogViews.loading': 'Loading chart...',
'home.chart.placeholder.comingSoon': 'Coming soon', 'home.chart.blogViews.error': 'Unable to load blog view activity.',
'home.chart.blogViews.bar': '{day}: {count} views',
'home.chart.productViews.title': 'Product views',
'home.chart.productViews.subtitle': 'Product page views in the last 30 days',
'home.chart.productViews.legend': 'Views ({count})',
'home.chart.productViews.loading': 'Loading chart...',
'home.chart.productViews.error': 'Unable to load product view activity.',
'home.chart.productViews.bar': '{day}: {count} views',
'home.chart.websiteViews.title': 'Website views',
'home.chart.websiteViews.subtitle': 'Storefront page views in the last 30 days',
'home.chart.websiteViews.legend': 'Views ({count})',
'home.chart.websiteViews.loading': 'Loading chart...',
'home.chart.websiteViews.error': 'Unable to load website view activity.',
'home.chart.websiteViews.bar': '{day}: {count} views',
'home.chart.portfolioViews.title': 'Portfolio views',
'home.chart.portfolioViews.subtitle': 'Portfolio page views in the last 30 days',
'home.chart.portfolioViews.legend': 'Views ({count})',
'home.chart.portfolioViews.loading': 'Loading chart...',
'home.chart.portfolioViews.error': 'Unable to load portfolio view activity.',
'home.chart.portfolioViews.bar': '{day}: {count} views',
'products.overview.subtitle': 'Manage your products, inventory and categories.', 'products.overview.subtitle': 'Manage your products, inventory and categories.',
'products.card.list.desc': 'View, edit and manage all your existing products.', 'products.card.list.desc': 'View, edit and manage all your existing products.',
@@ -2510,9 +2529,28 @@ const fa: Record<MessageKey, string> = {
'home.chart.customersJoined.activeBar': '{month}: {count} فعال', 'home.chart.customersJoined.activeBar': '{month}: {count} فعال',
'home.chart.blogViews.title': 'بازدید مقالات', 'home.chart.blogViews.title': 'بازدید مقالات',
'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته', 'home.chart.blogViews.subtitle': 'بازدید مقالات در ۳۰ روز گذشته',
'home.chart.placeholder.title': 'نمودار', 'home.chart.blogViews.legend': 'بازدید ({count})',
'home.chart.placeholder.subtitle': 'این نمودار به‌زودی در دسترس خواهد بود.', 'home.chart.blogViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.placeholder.comingSoon': 'به‌زودی', 'home.chart.blogViews.error': 'بارگذاری بازدید مقالات ممکن نشد.',
'home.chart.blogViews.bar': '{day}: {count} بازدید',
'home.chart.productViews.title': 'بازدید محصولات',
'home.chart.productViews.subtitle': 'بازدید صفحات محصول در ۳۰ روز گذشته',
'home.chart.productViews.legend': 'بازدید ({count})',
'home.chart.productViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.productViews.error': 'بارگذاری بازدید محصولات ممکن نشد.',
'home.chart.productViews.bar': '{day}: {count} بازدید',
'home.chart.websiteViews.title': 'بازدید وب‌سایت',
'home.chart.websiteViews.subtitle': 'بازدید صفحات فروشگاه در ۳۰ روز گذشته',
'home.chart.websiteViews.legend': 'بازدید ({count})',
'home.chart.websiteViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.websiteViews.error': 'بارگذاری بازدید وب‌سایت ممکن نشد.',
'home.chart.websiteViews.bar': '{day}: {count} بازدید',
'home.chart.portfolioViews.title': 'بازدید نمونه‌کارها',
'home.chart.portfolioViews.subtitle': 'بازدید صفحات نمونه‌کار در ۳۰ روز گذشته',
'home.chart.portfolioViews.legend': 'بازدید ({count})',
'home.chart.portfolioViews.loading': 'در حال بارگذاری نمودار...',
'home.chart.portfolioViews.error': 'بارگذاری بازدید نمونه‌کارها ممکن نشد.',
'home.chart.portfolioViews.bar': '{day}: {count} بازدید',
'products.overview.subtitle': 'محصولات، موجودی و دسته‌بندی‌ها را مدیریت کنید.', 'products.overview.subtitle': 'محصولات، موجودی و دسته‌بندی‌ها را مدیریت کنید.',
'products.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.', 'products.card.list.desc': 'همه محصولات موجود را ببینید، ویرایش و مدیریت کنید.',
@@ -39,3 +39,20 @@ export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal)
{ auth: true, signal }, { auth: true, signal },
) )
} }
export type WebsiteViewKind = 'website' | 'product' | 'portfolio' | 'blog'
export async function getViewDailyActivity(
kind: WebsiteViewKind,
days = 30,
signal?: AbortSignal,
) {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return apiRequest<DualDailyActivityResponse>(
`/businesses/${businessId}/analytics/views/activity?kind=${kind}&days=${days}`,
{ auth: true, signal },
)
}
@@ -32,6 +32,9 @@ export const HOME_CHART_IDS = [
'customers_joined_1y', 'customers_joined_1y',
'blog_views_30d', 'blog_views_30d',
'products_added_1y', 'products_added_1y',
'product_views_30d',
'website_views_30d',
'portfolio_views_30d',
] as const ] as const
export type HomeChartId = (typeof HOME_CHART_IDS)[number] export type HomeChartId = (typeof HOME_CHART_IDS)[number]
@@ -56,6 +56,9 @@ export const HOME_CHART_IDS = [
'customers_joined_1y', 'customers_joined_1y',
'blog_views_30d', 'blog_views_30d',
'products_added_1y', 'products_added_1y',
'product_views_30d',
'website_views_30d',
'portfolio_views_30d',
] as const ] as const
export type HomeChartId = (typeof HOME_CHART_IDS)[number] export type HomeChartId = (typeof HOME_CHART_IDS)[number]
@@ -64,8 +67,11 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
none: 'No chart', none: 'No chart',
orders_30d: 'Orders in past 30 days', orders_30d: 'Orders in past 30 days',
customers_joined_1y: 'Joined users in last year', customers_joined_1y: 'Joined users in last year',
blog_views_30d: 'Blog visit in last month', blog_views_30d: 'Blog visit in last 30 days',
products_added_1y: 'Added product in last year', products_added_1y: 'Added product in last year',
product_views_30d: 'Product visit in last 30 days',
website_views_30d: 'Website visit in last 30 days',
portfolio_views_30d: 'Portfolio visit in last 30 days',
} }
/** /**
+1 -1
View File
@@ -276,7 +276,7 @@ Products accept optional `brandId` on create/update.
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`, `website` - `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`, `website`
- `website.specialProductsSource`: `product` \| `store_item`. Unset → `store_item` if the store module is enabled, otherwise `product`. - `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.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`); `none` hides that slot; defaults orders + customers. - `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 need `POST /tenants/{domain}/analytics/views` from the storefront.
- Always-on (not in modules list): business — customers, website, profile, settings, home; customer — home, profile, addresses, orders, favorites - Always-on (not in modules list): business — customers, website, profile, settings, home; customer — home, profile, addresses, orders, favorites
- Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts` + `specialProductsSource` - Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts` + `specialProductsSource`