diff --git a/projects/js-packages/storybook/changelog/register-premium-analytics-widgets-path b/projects/js-packages/storybook/changelog/register-premium-analytics-widgets-path new file mode 100644 index 000000000000..a29648a3733d --- /dev/null +++ b/projects/js-packages/storybook/changelog/register-premium-analytics-widgets-path @@ -0,0 +1,3 @@ +Significance: patch +Type: changed +Comment: Register the premium-analytics widgets directory as a story discovery path. diff --git a/projects/js-packages/storybook/storybook/projects.js b/projects/js-packages/storybook/storybook/projects.js index 6601798f40a9..f5261e5205aa 100644 --- a/projects/js-packages/storybook/storybook/projects.js +++ b/projects/js-packages/storybook/storybook/projects.js @@ -14,6 +14,7 @@ export const projects = [ 'projects/js-packages/social-logos/src/react', 'projects/packages/my-jetpack/_inc/components', 'projects/packages/premium-analytics/packages', + 'projects/packages/premium-analytics/widgets', 'projects/packages/publicize/_inc/components', 'projects/packages/search/src/dashboard/components', 'projects/packages/videopress/src/client/admin/components', diff --git a/projects/packages/premium-analytics/changelog/add-top-posts-widget-storybook b/projects/packages/premium-analytics/changelog/add-top-posts-widget-storybook new file mode 100644 index 000000000000..fb3d577fbe6d --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-top-posts-widget-storybook @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Storybook: Add a prop-driven Top posts & pages widget story under Packages/Premium Analytics/Widgets. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx index 099a44db2d0d..dfee85fbaef4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx @@ -5,7 +5,8 @@ import { LeaderboardChartUnresponsive as BaseLeaderboardChart, useGlobalChartsContext, Legend, - hexToRgba, + lightenHexColor, + normalizeColorToHex, } from '@automattic/charts'; import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { Icon, Stack } from '@wordpress/ui'; @@ -136,16 +137,21 @@ export function LeaderboardChart( { ); /** - * Get chart colors for legend + * Bar color for overlay-label mode. + * + * The label sits on top of the bar, so the bar needs to read as a faint + * tint of the primary color. We can't pass a translucent color through the + * chart's `primaryColor` prop — it resolves the value via getElementStyles, + * which strips the alpha channel. Instead we pre-blend the primary with + * white to produce the opaque equivalent of an 8% alpha fill. */ - const chartColors = useMemo( () => { - const { color: primaryColor } = getElementStyles( { index: 0 } ); - if ( ! withComparison ) { - return { primaryColor }; + const barColor = useMemo( () => { + if ( ! withOverlayLabel ) { + return undefined; } - const { color: secondaryColor } = getElementStyles( { index: 1 } ); - return { primaryColor, secondaryColor }; - }, [ withComparison, getElementStyles ] ); + const { color: primaryColor } = getElementStyles( { index: 0 } ); + return lightenHexColor( normalizeColorToHex( primaryColor ), 0.92 ); + }, [ withOverlayLabel, getElementStyles ] ); /** * Merge theme bar border radius with style prop. @@ -183,7 +189,7 @@ export function LeaderboardChart( { withComparison={ withComparison } valueFormatter={ valueFormatter } legendLabels={ legendLabels } - primaryColor={ withOverlayLabel ? hexToRgba( chartColors.primaryColor, 0.08 ) : undefined } + primaryColor={ barColor } withOverlayLabel={ withOverlayLabel } showLegend={ false } style={ chartStyle } diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts index 121c13e7227b..40aa6c4178e4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts @@ -18,3 +18,4 @@ export { } from './chart-leaderboard'; export { BarChart, type BarChartProps, type BarChartData, type BarChartStyle } from './chart-bar'; export { ChartEmptyState, type ChartEmptyStateProps } from './chart-empty-state'; +export { WidgetLoadingOverlay } from './widget-loading-overlay'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index 9b561dd55f5b..87259b94517c 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -20,6 +20,7 @@ export { type BarChartProps, type BarChartData, type BarChartStyle, + WidgetLoadingOverlay, } from './components'; /** diff --git a/projects/packages/premium-analytics/tsconfig.json b/projects/packages/premium-analytics/tsconfig.json index 3619d8447603..202a51a70064 100644 --- a/projects/packages/premium-analytics/tsconfig.json +++ b/projects/packages/premium-analytics/tsconfig.json @@ -9,5 +9,5 @@ "@jetpack-premium-analytics/*": [ "./packages/*/src" ] } }, - "include": [ "routes/**/*", "packages/**/*", "types/**/*" ] + "include": [ "routes/**/*", "packages/**/*", "types/**/*", "widgets/**/*" ] } diff --git a/projects/packages/premium-analytics/widgets/top-posts/index.ts b/projects/packages/premium-analytics/widgets/top-posts/index.ts new file mode 100644 index 000000000000..0abccc7e86cf --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/index.ts @@ -0,0 +1,2 @@ +export { TopPostsWidget, type TopPostsWidgetProps } from './top-posts-widget'; +export type { TopPostRow } from './types'; diff --git a/projects/packages/premium-analytics/widgets/top-posts/stories/top-posts-widget.stories.tsx b/projects/packages/premium-analytics/widgets/top-posts/stories/top-posts-widget.stories.tsx new file mode 100644 index 000000000000..e0f82b0c5c25 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/stories/top-posts-widget.stories.tsx @@ -0,0 +1,196 @@ +/** + * Internal dependencies + */ +import { withChartTheme } from '../../../packages/widgets-toolkit/src/stories/with-chart-theme'; +import { TopPostsWidget } from '../top-posts-widget'; +import type { TopPostRow } from '../types'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof TopPostsWidget > = { + title: 'Packages/Premium Analytics/Widgets/TopPosts', + component: TopPostsWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'The "Top posts & pages" widget. Renders the most-viewed posts and pages for the period as a leaderboard, with each row linking to the published content. This is a presentational component — it takes already-fetched rows via props and handles the loading, error, empty, and populated states.', + }, + }, + }, + decorators: [ withChartTheme ], +}; + +export default meta; + +type Story = StoryObj< typeof TopPostsWidget >; + +const mockRows: TopPostRow[] = [ + { + label: 'How we cut our build times in half', + value: 12840, + previousValue: 9870, + href: 'https://example.com/cut-build-times-in-half', + type: 'post', + }, + { + label: 'Pricing', + value: 9320, + previousValue: 10110, + href: 'https://example.com/pricing', + type: 'page', + }, + { + label: '10 lessons from scaling to a million users', + value: 7610, + previousValue: 5400, + href: 'https://example.com/lessons-scaling-million-users', + type: 'post', + }, + { + label: 'About us', + value: 4180, + previousValue: 4360, + href: 'https://example.com/about', + type: 'page', + }, + { + label: 'A practical guide to feature flags', + value: 2950, + previousValue: 0, + href: 'https://example.com/guide-to-feature-flags', + type: 'post', + }, +]; + +const mockLongLabelRows: TopPostRow[] = [ + { + label: + 'An exhaustively long, keyword-stuffed headline that almost certainly needs to be truncated before it overflows the row', + value: 8400, + href: 'https://example.com/very-long-headline-that-needs-truncation', + type: 'post', + }, + { + label: 'Frequently asked questions about billing, refunds, and account management', + value: 5120, + href: 'https://example.com/faq-billing-refunds-account-management', + type: 'page', + }, + { + label: 'Changelog', + value: 2010, + href: 'https://example.com/changelog', + type: 'page', + }, +]; + +/** + * Default populated state — a mix of posts and pages ranked by views. + */ +export const Default: Story = { + args: { + rows: mockRows, + }, +}; + +/** + * Comparison state — each value shows its change versus the previous period + * (green for gains, red for losses), driven by each row's `previousValue`. + * Mirrors the overlay comparison mode of the toolkit's `LeaderboardChart`. + */ +export const WithComparison: Story = { + args: { + rows: mockRows, + withComparison: true, + showLegend: true, + legendLabels: { + primary: 'Jun 1 – 18, 2026', + comparison: 'May 14 – 31, 2026', + }, + }, +}; + +/** + * Loading state — the chart renders its loading overlay while data is fetched. + */ +export const Loading: Story = { + args: { + rows: [], + isLoading: true, + }, +}; + +/** + * Empty state — no views were recorded for the selected period. + */ +export const NoViews: Story = { + args: { + rows: [], + }, +}; + +/** + * Error state — the report could not be loaded. + */ +export const ErrorState: Story = { + args: { + isError: true, + }, +}; + +/** + * Long titles are truncated with an ellipsis so rows stay single-line. + */ +export const LongLabels: Story = { + args: { + rows: mockLongLabelRows, + }, +}; + +/** + * Creates a decorator that wraps the story in a fixed-size container so the + * widget's responsiveness can be inspected at a given width. + * + * @param width - The container width (any CSS length). + * @param [height] - The container height; defaults to `auto`. + * @return A Storybook decorator. + */ +const createSizeDecorator = ( width: string, height = 'auto' ): Decorator => { + return Story => ( +
+ +
+ ); +}; + +/** + * Medium container (448px / md breakpoint). + */ +export const SizeMedium: Story = { + args: { + rows: mockRows, + }, + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint). + */ +export const SizeLarge: Story = { + args: { + rows: mockRows, + }, + decorators: [ createSizeDecorator( '576px' ) ], +}; diff --git a/projects/packages/premium-analytics/widgets/top-posts/top-posts-widget.module.css b/projects/packages/premium-analytics/widgets/top-posts/top-posts-widget.module.css new file mode 100644 index 000000000000..b1eeeb18f676 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/top-posts-widget.module.css @@ -0,0 +1,22 @@ +.labelLink { + display: block; + + /* Vertical padding sets the overlay bar height — the bar fills the row, + whose height is driven by this label (there is no image to size it). */ + padding-block: var(--wpds-dimension-padding-md, 12px); + + /* Inset the text from the bar's rounded left edge. The chart applies this to + its default `.label` in overlay mode (`.is-overlay .label`), but a custom + label element bypasses that rule, so we mirror it here. */ + padding-inline-start: var(--wpds-dimension-padding-sm, 8px); + overflow: hidden; + color: inherit; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.labelLink:hover, +.labelLink:focus { + text-decoration: underline; +} diff --git a/projects/packages/premium-analytics/widgets/top-posts/top-posts-widget.tsx b/projects/packages/premium-analytics/widgets/top-posts/top-posts-widget.tsx new file mode 100644 index 000000000000..60b71a283c8c --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/top-posts-widget.tsx @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Text } from '@wordpress/ui'; +import { + LeaderboardChart, + WidgetLoadingOverlay, + calculateDelta, + type LeaderboardChartData, + type LegendLabels, +} from '@jetpack-premium-analytics/widgets-toolkit'; +/** + * Internal dependencies + */ +import styles from './top-posts-widget.module.css'; +import type { TopPostRow } from './types'; + +export type TopPostsWidgetProps = { + /** + * Normalized top-posts rows to render. When omitted, the empty state is shown + * (unless `isLoading` is set). + */ + rows?: TopPostRow[]; + /** + * When `true`, a loading overlay is rendered instead of data. + */ + isLoading?: boolean; + /** + * When `true`, an error message is rendered in place of the chart. + */ + isError?: boolean; + /** + * When `true`, render the comparison (previous-period) delta next to each + * value, using `previousValue` from each row. Mirrors the overlay + * comparison mode of the toolkit's `LeaderboardChart`. + */ + withComparison?: boolean; + /** + * When `true`, show the period legend below the chart. Requires + * `legendLabels` to be meaningful. + */ + showLegend?: boolean; + /** + * Custom legend labels for the current/comparison periods. + */ + legendLabels?: LegendLabels; +}; + +/** + * Renders a post/page title as a link that opens in a new tab. The link fills + * its row so the leaderboard overlay bar gets its height from the label. + * + * @param props - Component props. + * @param props.label - The post/page title. + * @param props.href - The published URL of the post/page. + * @return The rendered label link. + */ +const TopPostLabel = ( { label, href }: { label: string; href: string } ) => ( + + { label } + +); + +/** + * Maps normalized top-posts rows onto the shape `LeaderboardChart` expects. + * Current shares are computed relative to the most-viewed row so the overlay + * bars are proportional. When `withComparison` is set, previous-period shares + * and per-row deltas are derived from each row's `previousValue`; otherwise + * the comparison fields are zeroed. + * + * @param rows - The normalized top-posts rows. + * @param withComparison - Whether to derive previous-period shares and deltas. + * @return The leaderboard chart data. + */ +function buildLeaderboardData( rows: TopPostRow[], withComparison: boolean ): LeaderboardChartData { + // `1` guards against division by zero when every value is 0. + const maxCurrentViews = Math.max( ...rows.map( row => row.value ), 1 ); + const maxPreviousViews = Math.max( ...rows.map( row => row.previousValue ?? 0 ), 1 ); + + return rows.map( ( row, index ) => { + const previousValue = row.previousValue ?? 0; + + return { + id: `${ index }-${ row.href }`, + label: , + currentValue: row.value, + currentShare: ( row.value / maxCurrentViews ) * 100, + previousValue, + previousShare: + withComparison && previousValue > 0 ? ( previousValue / maxPreviousViews ) * 100 : 0, + delta: withComparison ? calculateDelta( row.value, previousValue ) : 0, + }; + } ); +} + +/** + * "Top posts & pages" widget. Renders the most-viewed posts and pages for the + * period as a leaderboard, each row linking to the published content. + * + * This is a presentational component: it takes already-fetched rows via props + * and is responsible only for the loading, error, empty, and populated states. + * + * @param props - Component props. + * @param props.rows - Normalized top-posts rows to render. + * @param props.isLoading - Whether the chart should render its loading overlay. + * @param props.isError - Whether to render an error message in place of the chart. + * @param props.withComparison - Whether to render previous-period deltas. + * @param props.showLegend - Whether to show the period legend below the chart. + * @param props.legendLabels - Custom labels for the current/comparison periods. + * @return The rendered widget. + */ +export const TopPostsWidget = ( { + rows = [], + isLoading = false, + isError = false, + withComparison = false, + showLegend = false, + legendLabels, +}: TopPostsWidgetProps ) => { + if ( isError ) { + return { __( 'Unable to load top posts.', 'jetpack-premium-analytics' ) }; + } + + if ( isLoading && ( ! rows || rows.length === 0 ) ) { + return ; + } + + return ( + + ); +}; diff --git a/projects/packages/premium-analytics/widgets/top-posts/types.ts b/projects/packages/premium-analytics/widgets/top-posts/types.ts new file mode 100644 index 000000000000..c7755895a72d --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/types.ts @@ -0,0 +1,27 @@ +/** + * A single normalized top-posts row, as produced by the Jetpack Stats + * "Top posts & pages" data layer (`useReportTopPosts` in `@jetpack-premium-analytics/data`). + */ +export type TopPostRow = { + /** + * Post or page title. + */ + label: string; + /** + * View count for the period. + */ + value: number; + /** + * View count for the comparison (previous) period. Only used when the widget + * is rendered with `withComparison`; omit it for single-period views. + */ + previousValue?: number; + /** + * URL of the published post/page. + */ + href: string; + /** + * Post type, e.g. `post` or `page`. + */ + type: string; +};