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:
co-authored by
Cursor
parent
468bb87fcf
commit
202ac7d4bd
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user