Skip to content

Added new top referrers endpoint for post analytics #23149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
21 changes: 19 additions & 2 deletions apps/admin-x-framework/src/api/stats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Meta, createQuery} from '../utils/api/hooks';
import {Meta, createQuery, createQueryWithId} from '../utils/api/hooks';

// Types

Expand Down Expand Up @@ -48,12 +48,24 @@ export type TopPostsStatsResponseType = {
meta: Meta;
};

export type PostReferrerStatItem = {
source: string;
free_members: number;
paid_members: number;
mrr: number;
};

export type PostReferrersResponseType = {
stats: PostReferrerStatItem[];
meta: Meta;
};

// Requests

const dataType = 'TopContentResponseType';
const memberCountHistoryDataType = 'MemberCountHistoryResponseType';
const topPostsStatsDataType = 'TopPostsStatsResponseType';

const postReferrersDataType = 'PostReferrersResponseType';
export const useTopContent = createQuery<TopContentResponseType>({
dataType,
path: '/stats/top-content/'
Expand All @@ -68,3 +80,8 @@ export const useTopPostsStats = createQuery<TopPostsStatsResponseType>({
dataType: topPostsStatsDataType,
path: '/stats/top-posts/'
});

export const usePostReferrers = createQueryWithId<PostReferrersResponseType>({
dataType: postReferrersDataType,
path: id => `/stats/posts/${id}/top-referrers`
});
12 changes: 12 additions & 0 deletions apps/posts/src/hooks/usePostReferrers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {useMemo} from 'react';
import {usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats';

export const usePostReferrers = (postId: string) => {
const {data: postReferrerResponse, isLoading} = usePostReferrersAPI(postId);
const stats = useMemo(() => postReferrerResponse?.stats || [], [postReferrerResponse]);

return {
isLoading,
stats
};
};
34 changes: 13 additions & 21 deletions apps/posts/src/views/PostAnalytics/Growth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,18 @@ import PostAnalyticsContent from './components/PostAnalyticsContent';
import PostAnalyticsHeader from './components/PostAnalyticsHeader';
import PostAnalyticsLayout from './layout/PostAnalyticsLayout';
import {Card, CardContent, CardDescription, CardHeader, CardTitle, LucideIcon, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ViewHeader, ViewHeaderActions, formatNumber} from '@tryghost/shade';

import {useParams} from '@tryghost/admin-x-framework';
import {usePostReferrers} from '../../hooks/usePostReferrers';
const STATS_DEFAULT_SOURCE_ICON_URL = 'https://static.ghost.org/v5.0.0/images/globe-icon.svg';

interface postAnalyticsProps {}

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

const isLoading = false;

const mockTopSources = [
{id: 'source-001', title: 'google.com', freeMembers: 17, paidMembers: 7, mrr: 8},
{id: 'source-002', title: 'twitter.com', freeMembers: 12, paidMembers: 5, mrr: 6},
{id: 'source-003', title: 'facebook.com', freeMembers: 9, paidMembers: 4, mrr: 5},
{id: 'source-004', title: 'linkedin.com', freeMembers: 8, paidMembers: 3, mrr: 4},
{id: 'source-005', title: 'reddit.com', freeMembers: 7, paidMembers: 2, mrr: 3},
{id: 'source-006', title: 'medium.com', freeMembers: 6, paidMembers: 2, mrr: 3}
];

return (
<PostAnalyticsLayout>
<ViewHeader className='items-end pb-4'>
Expand Down Expand Up @@ -55,7 +47,7 @@ const Growth: React.FC<postAnalyticsProps> = () => {
<LucideIcon.CircleDollarSign strokeWidth={1.5} />
</KpiCardIcon>
<KpiCardLabel>MRR</KpiCardLabel>
<KpiCardValue>+$180</KpiCardValue>
<KpiCardValue>+${formatNumber(180)}</KpiCardValue>
</KpiCard>
</div>
<Card>
Expand All @@ -75,22 +67,22 @@ const Growth: React.FC<postAnalyticsProps> = () => {
</TableRow>
</TableHeader>
<TableBody>
{mockTopSources.map(source => (
<TableRow key={source.id}>
{postReferrers?.map(row => (
<TableRow key={row.source}>
<TableCell>
<a className='inline-flex items-center gap-2 font-medium' href={`https://${source.title}`} rel="noreferrer" target='_blank'>
<a className='inline-flex items-center gap-2 font-medium' href={`https://${row.source}`} rel="noreferrer" target='_blank'>
<img
className="size-4"
src={`https://www.faviconextractor.com/favicon/${source.title || 'direct'}?larger=true`}
src={`https://www.faviconextractor.com/favicon/${row.source || 'direct'}?larger=true`}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.src = STATS_DEFAULT_SOURCE_ICON_URL;
}} />
<span>{source.title || 'Direct'}</span>
<span>{row.source || 'Direct'}</span>
</a>
Comment on lines +70 to 81
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Invalid URL & favicon fetch when row.source is falsey

href={https://${row.source}} and the favicon call both misbehave on an empty value (e.g., “Direct” traffic is typically stored as null). Either:

  1. Filter out null sources in the query (backend), or
  2. Handle them gracefully in the UI:
-<a ... href={`https://${row.source}`} ...>
+<a
+  ... 
+  href={row.source ? `https://${row.source}` : undefined}
+  rel="noopener noreferrer"
+  target={row.source ? '_blank' : undefined}
+>

Also add noopener to the rel attribute for security.

</TableCell>
<TableCell className='text-right font-mono text-sm'>+{formatNumber(source.freeMembers)}</TableCell>
<TableCell className='text-right font-mono text-sm'>+{formatNumber(source.paidMembers)}</TableCell>
<TableCell className='text-right font-mono text-sm'>+${source.mrr}</TableCell>
<TableCell className='text-right font-mono text-sm'>+{formatNumber(row.free_members)}</TableCell>
<TableCell className='text-right font-mono text-sm'>+{formatNumber(row.paid_members)}</TableCell>
<TableCell className='text-right font-mono text-sm'>+${formatNumber(row.mrr)}</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
39 changes: 39 additions & 0 deletions ghost/core/core/server/api/endpoints/stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,45 @@ const controller = {
async query(frame) {
return await statsService.api.getTopPosts(frame.options);
}
},
postReferrersAlpha: {
headers: {
cacheInvalidate: false
},
options: [
'order',
'limit',
'date_from',
'date_to',
'timezone'
],
data: [
'id'
],
validation: {
data: {
id: {
type: 'string',
required: true
}
}
},
permissions: {
docName: 'posts',
method: 'browse'
},
cache: statsService.cache,
generateCacheKeyData(frame) {
return {
method: 'getReferrersForPost',
data: {
id: frame.data.id
}
};
},
async query(frame) {
return await statsService.api.getReferrersForPost(frame.data.id, frame.options);
}
}
};

Expand Down
Loading