Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/admin-x-framework/src/api/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,55 @@ export const useSubscriptionStats = createQuery<SubscriptionStatsResponseType>({
path: '/stats/subscriptions/'
});

// Comments analytics overview

export type CommentsOverviewTotals = {
comments: number;
commenters: number;
reported: number;
};

export type CommentsOverviewSeriesItem = {
date: string;
count: number;
commenters: number;
reported: number;
};

export type CommentsOverviewTopPost = {
id: string;
title: string;
slug: string;
count: number;
};

export type CommentsOverviewTopMember = {
id: string;
name: string | null;
email: string;
count: number;
};

export type CommentsOverview = {
totals: CommentsOverviewTotals;
previousTotals: CommentsOverviewTotals | null;
series: CommentsOverviewSeriesItem[];
topPosts: CommentsOverviewTopPost[];
topMembers: CommentsOverviewTopMember[];
};

export type CommentsOverviewResponseType = {
stats: CommentsOverview[];
meta?: Meta;
};

const commentsOverviewDataType = 'CommentsOverviewResponseType';

export const useCommentsOverview = createQuery<CommentsOverviewResponseType>({
dataType: commentsOverviewDataType,
path: '/stats/comments/'
});

export const usePostStats = createQueryWithId<PostStatsResponseType>({
dataType: 'PostStatsResponseType',
path: id => `/stats/posts/${id}/stats/`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ const features: Feature[] = [{
title: 'Gift Subscriptions',
description: 'Allow site visitors to purchase gift subscriptions for others',
flag: 'giftSubscriptions'
}, {
title: 'Comment Analytics',
description: 'Show an analytics overview (KPIs, chart, top posts & commenters) above the comments moderation list',
flag: 'commentAnalytics'
}];

const AlphaFeatures: React.FC = () => {
Expand Down
21 changes: 20 additions & 1 deletion apps/posts/src/views/comments/comments.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CommentsAnalytics from './components/comments-analytics';
import CommentsContent from './components/comments-content';
import CommentsFilters from './components/comments-filters';
import CommentsHeader from './components/comments-header';
Expand All @@ -8,10 +9,17 @@ import {Button, EmptyIndicator, LoadingIndicator} from '@tryghost/shade/componen
import {LucideIcon} from '@tryghost/shade/utils';
import {createFilter} from '@tryghost/shade/patterns';
import {useBrowseComments} from '@tryghost/admin-x-framework/api/comments';
import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
import {useFilterState} from './hooks/use-filter-state';
import {useOverviewRange} from './hooks/use-overview-range';

const Comments: React.FC = () => {
const {filters, nql, setFilters, clearFilters, isSingleIdFilter} = useFilterState();
const {data: configData} = useBrowseConfig();
const analyticsEnabled = configData?.config?.labs?.commentAnalytics === true;

const {range, setRange, dateFrom, dateTo, timezone} = useOverviewRange();

const handleAddFilter = useCallback((field: string, value: string, operator: string = 'is') => {
setFilters((prevFilters) => {
// Remove any existing filter for the same field
Expand All @@ -36,8 +44,19 @@ const Comments: React.FC = () => {
// If we are fetching comments, but not fetching the next page and not refetching, we should show the loading indicator
const shouldShowLoading = isFetching && !isFetchingNextPage && !isRefetching;

const rail = analyticsEnabled ? (
<CommentsAnalytics
dateFrom={dateFrom}
dateTo={dateTo}
range={range}
setRange={setRange}
timezone={timezone}
onAddFilter={handleAddFilter}
/>
) : undefined;

return (
<CommentsLayout>
<CommentsLayout rail={rail}>
<CommentsHeader>
{!isSingleIdFilter && (
<CommentsFilters
Expand Down
73 changes: 73 additions & 0 deletions apps/posts/src/views/comments/components/comments-analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import OverviewDateRange from './overview-date-range';
import OverviewKpiTabs from './overview-kpi-tabs';
import OverviewTopMembers from './overview-top-members';
import OverviewTopPosts from './overview-top-posts';
import React, {useMemo} from 'react';
import {CommentsOverview as CommentsOverviewPayload, CommentsOverviewResponseType, useCommentsOverview} from '@tryghost/admin-x-framework/api/stats';

interface CommentsAnalyticsProps {
range: number;
setRange: (range: number) => void;
dateFrom: string;
dateTo: string;
timezone: string;
/**
* Applies a filter to the moderation list rendered alongside this rail.
* Used by top-posts/commenters row clicks and chart bar clicks.
*/
onAddFilter: (field: string, value: string, operator?: string) => void;
}

const EMPTY_OVERVIEW: CommentsOverviewPayload = {
totals: {comments: 0, commenters: 0, reported: 0},
previousTotals: null,
series: [],
topPosts: [],
topMembers: []
};

const CommentsAnalytics: React.FC<CommentsAnalyticsProps> = ({range, setRange, dateFrom, dateTo, timezone, onAddFilter}) => {
const searchParams = useMemo(() => ({
date_from: dateFrom,
date_to: dateTo,
timezone
}), [dateFrom, dateTo, timezone]);

const {data, isLoading} = useCommentsOverview({searchParams}) as {
data: CommentsOverviewResponseType | undefined;
isLoading: boolean;
};

const overview = data?.stats?.[0] ?? EMPTY_OVERVIEW;

return (
<div className='flex flex-col gap-5 pb-6' data-testid='comments-analytics'>
<div className='flex items-center justify-between gap-3'>
<h2 className='text-lg font-semibold tracking-tight'>Analytics</h2>
<OverviewDateRange range={range} onRangeChange={setRange} />
</div>
<OverviewKpiTabs
isLoading={isLoading}
previousTotals={data ? overview.previousTotals : undefined}
range={range}
series={data ? overview.series : undefined}
totals={data ? overview.totals : undefined}
onAddFilter={onAddFilter}
/>
<OverviewTopPosts
isLoading={isLoading}
posts={data ? overview.topPosts : undefined}
range={range}
onRowClick={postId => onAddFilter('post', postId)}
/>
<OverviewTopMembers
isLoading={isLoading}
members={data ? overview.topMembers : undefined}
range={range}
onRowClick={memberId => onAddFilter('author', memberId)}
/>
</div>
);
};

export default CommentsAnalytics;
7 changes: 6 additions & 1 deletion apps/posts/src/views/comments/components/comments-header.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import React from 'react';
import {Header} from '@tryghost/shade/primitives';

// The shade Header primitive is `@deprecated` and ships with `sticky top-0 z-50`
// plus `-mb-4 lg:-mb-8` baked in (to create the backdrop-blur overlap effect).
// We want the grid areas it provides — `[grid-area:title]`, `[grid-area:actions]`,
// `[grid-area:nav]` — because `CommentsFilters` positions itself against them.
// The overrides here neutralise the sticky+negative-margin without losing the grid.
const CommentsHeader: React.FC<React.PropsWithChildren> = ({children}) => {
return (
<Header className="relative pb-6! md:sticky" variant="inline-nav">
<Header className='!static !mb-0 pb-6! lg:!mb-0' variant='inline-nav'>
<Header.Title>Comments</Header.Title>
{children}
</Header>
Expand Down
30 changes: 26 additions & 4 deletions apps/posts/src/views/comments/components/comments-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import MainLayout from '@components/layout/main-layout';
import React from 'react';

const CommentsLayout: React.FC<{children: React.ReactNode}> = ({children}) => {
interface CommentsLayoutProps {
children: React.ReactNode;
/**
* Optional right-side column rendered alongside the main content on `lg+`.
* On smaller viewports it collapses above the main column so the analytics
* overview still appears at the top of the page.
*/
rail?: React.ReactNode;
}

const CommentsLayout: React.FC<CommentsLayoutProps> = ({children, rail}) => {
const body = rail ? (
<div className='block grow lg:grid lg:grid-cols-[minmax(0,1fr)_460px]'>
{/* `self-start` is required for sticky — grid cells otherwise stretch to the row's height. */}
<aside className='px-4 pt-4 lg:sticky lg:top-0 lg:col-start-2 lg:row-start-1 lg:max-h-screen lg:self-start lg:overflow-y-auto lg:border-l lg:border-border lg:px-8 lg:pt-8'>
{rail}
</aside>
<div className='flex min-w-0 flex-col lg:col-start-1 lg:row-start-1 lg:[&_.prose]:max-w-[70ch]'>
{children}
</div>
</div>
) : children;

return (
<MainLayout>
<div className="grid w-full grow">
<div className="flex h-full flex-col" data-testid="comments-page">
{children}
<div className='grid w-full grow'>
<div className='flex h-full flex-col' data-testid='comments-page'>
{body}
</div>
</div>
</MainLayout>
Expand Down
34 changes: 34 additions & 0 deletions apps/posts/src/views/comments/components/overview-date-range.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import {LucideIcon} from '@tryghost/shade/utils';
import {STATS_RANGES} from '@src/utils/constants';
import {Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue} from '@tryghost/shade/components';

interface OverviewDateRangeProps {
range: number;
onRangeChange: (value: number) => void;
}

const OPTIONS = Object.values(STATS_RANGES);

const OverviewDateRange: React.FC<OverviewDateRangeProps> = ({range, onRangeChange}) => {
return (
<Select value={`${range}`} onValueChange={value => onRangeChange(Number(value))}>
<SelectTrigger className='w-auto'>
<LucideIcon.Calendar className='mr-2' size={16} strokeWidth={1.5} />
<SelectValue placeholder='Select a period' />
</SelectTrigger>
<SelectContent align='end'>
<SelectGroup>
<SelectLabel>Period</SelectLabel>
{OPTIONS.map(option => (
<SelectItem key={option.value} value={`${option.value}`}>
{option.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
};

export default OverviewDateRange;
Loading
Loading