Skip to content
Open
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
20 changes: 20 additions & 0 deletions apps/admin-x-framework/src/api/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export type PostGrowthStatsResponseType = {
meta: Meta;
};

export type UtmGrowthStatItem = {
utm_value: string;
utm_type: string;
free_members: number;
paid_members: number;
mrr: number;
};

export type UtmGrowthStatsResponseType = {
stats: UtmGrowthStatItem[];
meta: Meta;
};

export type MrrHistoryItem = {
date: string;
mrr: number;
Expand Down Expand Up @@ -203,6 +216,7 @@ const newsletterStatsDataType = 'NewsletterStatsResponseType';
const newsletterSubscriberStatsDataType = 'NewsletterSubscriberStatsResponseType';

const postGrowthStatsDataType = 'PostGrowthStatsResponseType';
const utmGrowthStatsDataType = 'UtmGrowthStatsResponseType';
const mrrHistoryDataType = 'MrrHistoryResponseType';
const topPostViewsDataType = 'TopPostViewsResponseType';
const subscriptionStatsDataType = 'SubscriptionStatsResponseType';
Expand Down Expand Up @@ -231,6 +245,12 @@ export const usePostGrowthStats = createQueryWithId<PostGrowthStatsResponseType>
dataType: postGrowthStatsDataType,
path: id => `/stats/posts/${id}/growth`
});

export const useUtmGrowthStats = createQuery<UtmGrowthStatsResponseType>({
dataType: utmGrowthStatsDataType,
path: '/stats/utm-growth/'
});

export const useMrrHistory = createQuery<MrrHistoryResponseType>({
dataType: mrrHistoryDataType,
path: '/stats/mrr/'
Expand Down
127 changes: 102 additions & 25 deletions apps/posts/src/views/PostAnalytics/Growth/components/GrowthSources.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import React from 'react';
import React, {useState} from 'react';
import SourceIcon from '../../components/SourceIcon';
import {BaseSourceData, ProcessedSourceData, extendSourcesWithPercentages, processSources, useNavigate} from '@tryghost/admin-x-framework';
import {Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, EmptyIndicator, LucideIcon, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn, formatNumber} from '@tryghost/shade';
import {BaseSourceData, ProcessedSourceData, extendSourcesWithPercentages, processSources, useNavigate, useParams} from '@tryghost/admin-x-framework';
import {Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, EmptyIndicator, GrowthCampaignType, GrowthTabType, LucideIcon, Separator, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, UtmGrowthTabs, cn, formatNumber, getUtmType} from '@tryghost/shade';
import {useAppContext} from '@src/App';
import {useGlobalData} from '@src/providers/PostAnalyticsContext';
import {useUtmGrowthStats} from '@tryghost/admin-x-framework/api/stats';

// Default source icon URL - apps can override this
const DEFAULT_SOURCE_ICON_URL = 'https://www.google.com/s2/favicons?domain=ghost.org&sz=64';

// Number of top sources to show in the preview card
const TOP_SOURCES_PREVIEW_LIMIT = 10;

interface SourcesTableProps {
data: ProcessedSourceData[] | null;
mode: 'visits' | 'growth';
Expand Down Expand Up @@ -107,16 +112,72 @@ export const GrowthSources: React.FC<SourcesCardProps> = ({
}) => {
const {appSettings} = useAppContext();
const navigate = useNavigate();
const {data: globalData} = useGlobalData();
const {postId} = useParams();
const [selectedTab, setSelectedTab] = useState<GrowthTabType>('sources');
const [selectedCampaign, setSelectedCampaign] = useState<GrowthCampaignType>('');

// Check if UTM tracking is enabled in labs
const utmTrackingEnabled = globalData?.labs?.utmTracking || false;

const shouldFetchUtmData = utmTrackingEnabled
&& selectedTab === 'campaigns'
&& !!selectedCampaign
&& !!postId;

const utmType = getUtmType(selectedCampaign);
const {data: utmData} = useUtmGrowthStats({
searchParams: {
utm_type: utmType,
post_id: postId || ''
},
enabled: shouldFetchUtmData
});

// Select and transform the appropriate data based on current view
const displayData = React.useMemo(() => {
const isShowingCampaigns = selectedTab === 'campaigns' && selectedCampaign;

if (!isShowingCampaigns) {
return data;
}

if (!utmData?.stats) {
return null;
}

return utmData.stats.map(item => ({
...item,
source: item.utm_value || '(not set)'
}));
}, [data, utmData, selectedTab, selectedCampaign]);

// Process and group sources data with pre-computed icons and display values
const processedData = React.useMemo(() => {
const processedData = React.useMemo((): ProcessedSourceData[] => {
// UTM campaigns: UTM values are not domains, so don't show icons or links
if (selectedTab === 'campaigns' && selectedCampaign && displayData) {
return displayData.map(item => ({
source: String(item.source || '(not set)'),
visits: 0,
isDirectTraffic: false,
iconSrc: '',
displayName: String(item.source || '(not set)'),
linkUrl: undefined,
free_members: item.free_members || 0,
paid_members: item.paid_members || 0,
mrr: item.mrr || 0
}));
}

// For regular sources, use the standard processing
return processSources({
data,
data: displayData,
mode,
siteUrl,
siteIcon,
defaultSourceIconUrl
});
}, [data, siteUrl, siteIcon, mode, defaultSourceIconUrl]);
}, [displayData, siteUrl, siteIcon, mode, defaultSourceIconUrl, selectedTab, selectedCampaign]);

// Extend processed data with percentage values for visits mode
const extendedData = React.useMemo(() => {
Expand All @@ -127,16 +188,17 @@ export const GrowthSources: React.FC<SourcesCardProps> = ({
});
}, [processedData, totalVisitors, mode]);

const topSources = extendedData.slice(0, 10);
const topSources = extendedData.slice(0, TOP_SOURCES_PREVIEW_LIMIT);

// Generate description based on mode and range
// Generate title and description based on mode, tab, and campaign
const cardTitle = selectedTab === 'campaigns' && selectedCampaign ? selectedCampaign : title;
const cardDescription = description || (
mode === 'growth'
? 'Where did your growth come from?'
: `How readers found your ${range ? 'site' : 'post'}${range && getPeriodText ? ` ${getPeriodText(range)}` : ''}`
);

const sheetTitle = mode === 'growth' ? 'Sources' : 'Top sources';
const sheetTitle = selectedTab === 'campaigns' && selectedCampaign ? selectedCampaign : (mode === 'growth' ? 'Sources' : 'Top sources');
const sheetDescription = mode === 'growth'
? 'Where did your growth come from?'
: `How readers found your ${range ? 'site' : 'post'}${range && getPeriodText ? ` ${getPeriodText(range)}` : ''}`;
Expand All @@ -145,7 +207,7 @@ export const GrowthSources: React.FC<SourcesCardProps> = ({
<Card className={cn('group/datalist w-full max-w-[calc(100vw-64px)] overflow-x-auto sidebar:max-w-[calc(100vw-64px-280px)]', className)} data-testid='top-sources-card'>
{topSources.length <= 0 &&
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardTitle>{cardTitle}</CardTitle>
<CardDescription>{cardDescription}</CardDescription>
</CardHeader>
}
Expand All @@ -164,21 +226,36 @@ export const GrowthSources: React.FC<SourcesCardProps> = ({
<LucideIcon.Activity />
</EmptyIndicator>
) : topSources.length > 0 ? (
<SourcesTable
data={topSources}
defaultSourceIconUrl={defaultSourceIconUrl}
getPeriodText={getPeriodText}
headerStyle='card'
mode={mode}
range={range}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{cardDescription}</CardDescription>
</CardHeader>
</SourcesTable>
<>
{utmTrackingEnabled && mode === 'growth' && (
<>
<div className='mb-4'>
<UtmGrowthTabs
selectedCampaign={selectedCampaign}
selectedTab={selectedTab}
onCampaignChange={setSelectedCampaign}
onTabChange={setSelectedTab}
/>
</div>
<Separator />
</>
)}
<SourcesTable
data={topSources}
defaultSourceIconUrl={defaultSourceIconUrl}
getPeriodText={getPeriodText}
headerStyle='card'
mode={mode}
range={range}
>
<CardHeader>
<CardTitle>{cardTitle}</CardTitle>
<CardDescription>{cardDescription}</CardDescription>
</CardHeader>
</SourcesTable>
</>
Comment on lines +243 to +256
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Review the CardHeader placement inside table structure.

The CardHeader component (lines 251-254) is being passed as children to SourcesTable and will be rendered inside a <th> element (see line 33 where {children} appears in TableHead). This creates semantically incorrect HTML with complex div structures nested inside table header cells.

While the headerStyle='card' and variant='cardhead' suggest this might be intentional, consider:

  1. Accessibility: Screen readers expect simple text content in <th> elements
  2. HTML semantics: Card components are block-level containers and shouldn't be nested in table headers
  3. Maintainability: This pattern is non-standard and may confuse future developers

Consider refactoring to render the CardHeader outside the SourcesTable, similar to how it's done when topSources.length <= 0 (lines 209-212). The table should only contain tabular data in its header, not complex component structures.

Alternative structure:

 <>
   {utmTrackingEnabled && mode === 'growth' && (
     <>...</>
   )}
+  <CardHeader>
+    <CardTitle>{cardTitle}</CardTitle>
+    <CardDescription>{cardDescription}</CardDescription>
+  </CardHeader>
   <SourcesTable
     data={topSources}
     defaultSourceIconUrl={defaultSourceIconUrl}
     getPeriodText={getPeriodText}
-    headerStyle='card'
     mode={mode}
     range={range}
-  >
-    <CardHeader>
-      <CardTitle>{cardTitle}</CardTitle>
-      <CardDescription>{cardDescription}</CardDescription>
-    </CardHeader>
-  </SourcesTable>
+  />
 </>
🤖 Prompt for AI Agents
In apps/posts/src/views/PostAnalytics/Growth/components/GrowthSources.tsx around
lines 243 to 256, the CardHeader/CardTitle/CardDescription are currently passed
as children into SourcesTable which causes those block-level card components to
be rendered inside a <th>; move the CardHeader block out of the SourcesTable and
render it immediately above the table (the same approach used for the
empty-state case at lines ~209-212), keep headerStyle='card' on SourcesTable if
needed for styling, and ensure the table receives only tabular header content
(simple text or aria-labeled header cells) while the CardHeader remains a
sibling element for correct HTML semantics and accessibility.

) : (
<div className='py-20 text-center text-sm text-gray-700'>
<div className='py-20 text-center text-sm text-gray-700' data-testid='empty-sources-indicator'>
<EmptyIndicator
className='h-full'
description={mode === 'growth' && `Once someone signs up on this post, sources will show here`}
Expand All @@ -189,7 +266,7 @@ export const GrowthSources: React.FC<SourcesCardProps> = ({
</div>
)}
</CardContent>
{extendedData.length > 10 &&
{extendedData.length > TOP_SOURCES_PREVIEW_LIMIT &&
<CardFooter>
<Sheet>
<SheetTrigger asChild>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import {TableFilterDropdownTab, TableFilterTab, TableFilterTabs} from '../table-filter-tabs/table-filter-tabs';

export type GrowthCampaignType = '' | 'UTM sources' | 'UTM mediums' | 'UTM campaigns' | 'UTM contents' | 'UTM terms';
export type GrowthTabType = 'sources' | 'campaigns';

export const GROWTH_CAMPAIGN_TYPES = [
'UTM sources',
'UTM mediums',
'UTM campaigns',
'UTM contents',
'UTM terms'
] as const satisfies readonly Exclude<GrowthCampaignType, ''>[];

export const UTM_TYPE_MAP: Record<Exclude<GrowthCampaignType, ''>, string> = {
'UTM sources': 'utm_source',
'UTM mediums': 'utm_medium',
'UTM campaigns': 'utm_campaign',
'UTM contents': 'utm_content',
'UTM terms': 'utm_term'
};

export const getUtmType = (campaign: GrowthCampaignType): string => {
return campaign ? UTM_TYPE_MAP[campaign as Exclude<GrowthCampaignType, ''>] || '' : '';
};

export const GROWTH_CAMPAIGN_OPTIONS = GROWTH_CAMPAIGN_TYPES.map(type => ({
value: type,
label: type
}));

interface UtmGrowthTabsProps {
className?: string;
selectedTab: GrowthTabType;
onTabChange: (tab: GrowthTabType) => void;
selectedCampaign: GrowthCampaignType;
onCampaignChange: (campaign: GrowthCampaignType) => void;
}

export const UtmGrowthTabs: React.FC<UtmGrowthTabsProps> = ({
className,
selectedTab,
onTabChange,
selectedCampaign,
onCampaignChange
}) => {
const handleTabChange = (tab: string) => {
// Prevent switching to campaigns without selection
if (tab === 'campaigns' && !selectedCampaign) {
return;
}
onTabChange(tab as GrowthTabType);

// Clear campaign when switching away
if (tab !== 'campaigns') {
onCampaignChange('');
}
};

const handleCampaignChange = (campaign: string) => {
onCampaignChange(campaign as GrowthCampaignType);
onTabChange('campaigns');
};

return (
<TableFilterTabs
className={className}
selectedTab={selectedTab}
onTabChange={handleTabChange}
>
<TableFilterTab value='sources'>Sources</TableFilterTab>
<TableFilterDropdownTab
options={GROWTH_CAMPAIGN_OPTIONS}
placeholder='Campaigns'
selectedOption={selectedCampaign}
value='campaigns'
onOptionChange={handleCampaignChange}
/>
</TableFilterTabs>
);
};
2 changes: 2 additions & 0 deletions apps/shade/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export * from './components/layout/view-header';
export {default as PostShareModal} from './components/features/post_share_modal';
export * from './components/features/table-filter-tabs/table-filter-tabs';
export * from './components/features/utm-campaign-tabs/utm-campaign-tabs';
export * from './components/features/utm-growth-tabs/utm-growth-tabs';
export type {CampaignType, TabType} from './components/features/utm-campaign-tabs/utm-campaign-tabs';
export type {GrowthCampaignType, GrowthTabType} from './components/features/utm-growth-tabs/utm-growth-tabs';

// Third party components
export * as Recharts from 'recharts';
Expand Down
28 changes: 24 additions & 4 deletions apps/stats/src/views/Stats/Growth/Growth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import SortButton from '../components/SortButton';
import StatsHeader from '../layout/StatsHeader';
import StatsLayout from '../layout/StatsLayout';
import StatsView from '../layout/StatsView';
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, EmptyIndicator, LucideIcon, SkeletonTable, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsList, TabsTrigger, centsToDollars, formatDisplayDate, formatNumber} from '@tryghost/shade';
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, EmptyIndicator, GROWTH_CAMPAIGN_OPTIONS, GrowthCampaignType, LucideIcon, SkeletonTable, Table, TableBody, TableCell, TableFilterDropdownTab, TableHead, TableHeader, TableRow, Tabs, TabsList, TabsTrigger, centsToDollars, formatDisplayDate, formatNumber} from '@tryghost/shade';
import {CONTENT_TYPES, ContentType, getContentTitle, getGrowthContentDescription} from '@src/utils/content-helpers';
import {getClickHandler} from '@src/utils/url-helpers';
import {getPeriodText} from '@src/utils/chart-helpers';
Expand Down Expand Up @@ -39,13 +39,17 @@ type SourcesOrder = 'free_members desc' | 'paid_members desc' | 'mrr desc' | 'so
type UnifiedSortOrder = TopPostsOrder | SourcesOrder;

const Growth: React.FC = () => {
const {range, site} = useGlobalData();
const {range, site, data: globalData} = useGlobalData();
const navigate = useNavigate();
const [sortBy, setSortBy] = useState<UnifiedSortOrder>('free_members desc');
const [selectedContentType, setSelectedContentType] = useState<ContentType>(CONTENT_TYPES.POSTS_AND_PAGES);
const [selectedCampaign, setSelectedCampaign] = useState<GrowthCampaignType>('');
const [searchParams] = useSearchParams();
const {appSettings} = useAppContext();

// Check if UTM tracking is enabled
const utmTrackingEnabled = globalData?.labs?.utmTracking || false;

// Get the initial tab from URL search parameters
const initialTab = searchParams.get('tab') || 'total-members';

Expand Down Expand Up @@ -160,14 +164,29 @@ const Growth: React.FC = () => {
<TableHeader>
<TableRow className='[&>th]:h-auto [&>th]:pb-2 [&>th]:pt-0'>
<TableHead className='min-w-[320px] pl-0'>
<Tabs defaultValue={selectedContentType} variant='button-sm' onValueChange={(value: string) => {
<Tabs value={selectedCampaign ? 'campaigns' : selectedContentType} variant='button-sm' onValueChange={(value: string) => {
setSelectedContentType(value as ContentType);
// Clear campaign selection when switching away from campaigns
if (value !== 'campaigns') {
setSelectedCampaign('');
}
}}>
Comment on lines +167 to 173
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: setting content type to 'campaigns' causes invalid post_type in data fetch.

Don’t set selectedContentType when switching to campaigns.

Apply this diff:

-<Tabs value={selectedCampaign ? 'campaigns' : selectedContentType} variant='button-sm' onValueChange={(value: string) => {
-    setSelectedContentType(value as ContentType);
-    // Clear campaign selection when switching away from campaigns
-    if (value !== 'campaigns') {
-        setSelectedCampaign('');
-    }
-}}>
+<Tabs value={selectedCampaign ? 'campaigns' : selectedContentType} variant='button-sm' onValueChange={(value: string) => {
+    // Only update content type for content tabs
+    if (value !== 'campaigns') {
+        setSelectedContentType(value as ContentType);
+        setSelectedCampaign('');
+    }
+}}>

Optionally, consider using the shared UtmGrowthTabs component for consistency with Posts UI.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/stats/src/views/Stats/Growth/Growth.tsx around lines 167-173, the
onValueChange handler incorrectly sets selectedContentType to 'campaigns', which
produces an invalid post_type in the data fetch; change the handler so it does
NOT call setSelectedContentType when value === 'campaigns' (instead
setSelectedCampaign or handle campaign selection there), and keep the existing
logic that clears selectedCampaign when switching away from campaigns (i.e.,
only call setSelectedContentType(value as ContentType) when value !==
'campaigns').

<TabsList>
<TabsTrigger value={CONTENT_TYPES.POSTS_AND_PAGES}>Posts & pages</TabsTrigger>
<TabsTrigger value={CONTENT_TYPES.POSTS}>Posts</TabsTrigger>
<TabsTrigger value={CONTENT_TYPES.PAGES}>Pages</TabsTrigger>
<TabsTrigger value={CONTENT_TYPES.SOURCES}>Sources</TabsTrigger>
{utmTrackingEnabled && (
<TableFilterDropdownTab
options={GROWTH_CAMPAIGN_OPTIONS}
placeholder='Campaigns'
selectedOption={selectedCampaign}
value='campaigns'
onOptionChange={(campaign) => {
setSelectedCampaign(campaign as GrowthCampaignType);
}}
/>
)}
</TabsList>
</Tabs>
</TableHead>
Expand Down Expand Up @@ -196,10 +215,11 @@ const Growth: React.FC = () => {
}
</TableRow>
</TableHeader>
{selectedContentType === CONTENT_TYPES.SOURCES ?
{(selectedContentType === CONTENT_TYPES.SOURCES || selectedCampaign) ?
<GrowthSources
limit={20}
range={range}
selectedCampaign={selectedCampaign}
setSortBy={(newSortBy: SourcesOrder) => setSortBy(newSortBy)}
showViewAll={true}
sortBy={sortBy as SourcesOrder}
Expand Down
Loading