Skip to content

Commit 9704c9e

Browse files
authored
Added new top referrers endpoint for post analytics (#23149)
ref https://linear.app/ghost/issue/PROD-1542/adjust-conversion-attribution-to-reflect-free-members-and-paid-members On the new Post Analytics page, we display a table of the top sources for a particular post, sorted by free members, paid members and MRR. This commit adds the API endpoint for this data, and wires it up to the frontend.
1 parent 1ab13a0 commit 9704c9e

File tree

10 files changed

+510
-81
lines changed

10 files changed

+510
-81
lines changed

apps/admin-x-framework/src/api/stats.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Meta, createQuery} from '../utils/api/hooks';
1+
import {Meta, createQuery, createQueryWithId} from '../utils/api/hooks';
22

33
// Types
44

@@ -48,12 +48,24 @@ export type TopPostsStatsResponseType = {
4848
meta: Meta;
4949
};
5050

51+
export type PostReferrerStatItem = {
52+
source: string;
53+
free_members: number;
54+
paid_members: number;
55+
mrr: number;
56+
};
57+
58+
export type PostReferrersResponseType = {
59+
stats: PostReferrerStatItem[];
60+
meta: Meta;
61+
};
62+
5163
// Requests
5264

5365
const dataType = 'TopContentResponseType';
5466
const memberCountHistoryDataType = 'MemberCountHistoryResponseType';
5567
const topPostsStatsDataType = 'TopPostsStatsResponseType';
56-
68+
const postReferrersDataType = 'PostReferrersResponseType';
5769
export const useTopContent = createQuery<TopContentResponseType>({
5870
dataType,
5971
path: '/stats/top-content/'
@@ -68,3 +80,8 @@ export const useTopPostsStats = createQuery<TopPostsStatsResponseType>({
6880
dataType: topPostsStatsDataType,
6981
path: '/stats/top-posts/'
7082
});
83+
84+
export const usePostReferrers = createQueryWithId<PostReferrersResponseType>({
85+
dataType: postReferrersDataType,
86+
path: id => `/stats/posts/${id}/top-referrers`
87+
});
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {useMemo} from 'react';
2+
import {usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats';
3+
4+
export const usePostReferrers = (postId: string) => {
5+
const {data: postReferrerResponse, isLoading} = usePostReferrersAPI(postId);
6+
const stats = useMemo(() => postReferrerResponse?.stats || [], [postReferrerResponse]);
7+
8+
return {
9+
isLoading,
10+
stats
11+
};
12+
};

apps/posts/src/views/PostAnalytics/Growth.tsx

+13-21
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,18 @@ import PostAnalyticsContent from './components/PostAnalyticsContent';
44
import PostAnalyticsHeader from './components/PostAnalyticsHeader';
55
import PostAnalyticsLayout from './layout/PostAnalyticsLayout';
66
import {Card, CardContent, CardDescription, CardHeader, CardTitle, LucideIcon, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ViewHeader, ViewHeaderActions, formatNumber} from '@tryghost/shade';
7-
7+
import {useParams} from '@tryghost/admin-x-framework';
8+
import {usePostReferrers} from '../../hooks/usePostReferrers';
89
const STATS_DEFAULT_SOURCE_ICON_URL = 'https://static.ghost.org/v5.0.0/images/globe-icon.svg';
910

1011
interface postAnalyticsProps {}
1112

1213
const Growth: React.FC<postAnalyticsProps> = () => {
1314
// const {isLoading: isConfigLoading} = useGlobalData();
15+
const {postId} = useParams();
16+
const {stats: postReferrers, isLoading} = usePostReferrers(postId || '');
1417
// const {range} = useGlobalData();
1518

16-
const isLoading = false;
17-
18-
const mockTopSources = [
19-
{id: 'source-001', title: 'google.com', freeMembers: 17, paidMembers: 7, mrr: 8},
20-
{id: 'source-002', title: 'twitter.com', freeMembers: 12, paidMembers: 5, mrr: 6},
21-
{id: 'source-003', title: 'facebook.com', freeMembers: 9, paidMembers: 4, mrr: 5},
22-
{id: 'source-004', title: 'linkedin.com', freeMembers: 8, paidMembers: 3, mrr: 4},
23-
{id: 'source-005', title: 'reddit.com', freeMembers: 7, paidMembers: 2, mrr: 3},
24-
{id: 'source-006', title: 'medium.com', freeMembers: 6, paidMembers: 2, mrr: 3}
25-
];
26-
2719
return (
2820
<PostAnalyticsLayout>
2921
<ViewHeader className='items-end pb-4'>
@@ -55,7 +47,7 @@ const Growth: React.FC<postAnalyticsProps> = () => {
5547
<LucideIcon.CircleDollarSign strokeWidth={1.5} />
5648
</KpiCardIcon>
5749
<KpiCardLabel>MRR</KpiCardLabel>
58-
<KpiCardValue>+$180</KpiCardValue>
50+
<KpiCardValue>+${formatNumber(180)}</KpiCardValue>
5951
</KpiCard>
6052
</div>
6153
<Card>
@@ -75,22 +67,22 @@ const Growth: React.FC<postAnalyticsProps> = () => {
7567
</TableRow>
7668
</TableHeader>
7769
<TableBody>
78-
{mockTopSources.map(source => (
79-
<TableRow key={source.id}>
70+
{postReferrers?.map(row => (
71+
<TableRow key={row.source}>
8072
<TableCell>
81-
<a className='inline-flex items-center gap-2 font-medium' href={`https://${source.title}`} rel="noreferrer" target='_blank'>
73+
<a className='inline-flex items-center gap-2 font-medium' href={`https://${row.source}`} rel="noreferrer" target='_blank'>
8274
<img
8375
className="size-4"
84-
src={`https://www.faviconextractor.com/favicon/${source.title || 'direct'}?larger=true`}
76+
src={`https://www.faviconextractor.com/favicon/${row.source || 'direct'}?larger=true`}
8577
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
8678
e.currentTarget.src = STATS_DEFAULT_SOURCE_ICON_URL;
8779
}} />
88-
<span>{source.title || 'Direct'}</span>
80+
<span>{row.source || 'Direct'}</span>
8981
</a>
9082
</TableCell>
91-
<TableCell className='text-right font-mono text-sm'>+{formatNumber(source.freeMembers)}</TableCell>
92-
<TableCell className='text-right font-mono text-sm'>+{formatNumber(source.paidMembers)}</TableCell>
93-
<TableCell className='text-right font-mono text-sm'>+${source.mrr}</TableCell>
83+
<TableCell className='text-right font-mono text-sm'>+{formatNumber(row.free_members)}</TableCell>
84+
<TableCell className='text-right font-mono text-sm'>+{formatNumber(row.paid_members)}</TableCell>
85+
<TableCell className='text-right font-mono text-sm'>+${formatNumber(row.mrr)}</TableCell>
9486
</TableRow>
9587
))}
9688
</TableBody>

ghost/core/core/server/api/endpoints/stats.js

+39
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,45 @@ const controller = {
167167
async query(frame) {
168168
return await statsService.api.getTopPosts(frame.options);
169169
}
170+
},
171+
postReferrersAlpha: {
172+
headers: {
173+
cacheInvalidate: false
174+
},
175+
options: [
176+
'order',
177+
'limit',
178+
'date_from',
179+
'date_to',
180+
'timezone'
181+
],
182+
data: [
183+
'id'
184+
],
185+
validation: {
186+
data: {
187+
id: {
188+
type: 'string',
189+
required: true
190+
}
191+
}
192+
},
193+
permissions: {
194+
docName: 'posts',
195+
method: 'browse'
196+
},
197+
cache: statsService.cache,
198+
generateCacheKeyData(frame) {
199+
return {
200+
method: 'getReferrersForPost',
201+
data: {
202+
id: frame.data.id
203+
}
204+
};
205+
},
206+
async query(frame) {
207+
return await statsService.api.getReferrersForPost(frame.data.id, frame.options);
208+
}
170209
}
171210
};
172211

0 commit comments

Comments
 (0)