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
33 changes: 21 additions & 12 deletions apps/posts/src/hooks/use-gift-link-usage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {StatsConfig, useTinybirdQuery} from '@tryghost/admin-x-framework';
import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings';
import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
import {useMemo} from 'react';

Expand All @@ -8,18 +9,22 @@ export interface GiftLinkUsage {
}

interface GiftLinkVisitsRow {
gift: string;
gift_link: string;
visits: number | string;
views: number | string;
}

// Reads gift-link usage from the web-analytics pipeline (the same Tinybird
// mechanism every other analytics surface uses), keyed on the link token.
//
// Usage tracking is best-effort and entirely optional: analytics may be turned
// off (no statsConfig) or the query may error. In either case `usage` is
// `undefined` and the caller simply hides the count — the rest of the gift-link
// UI keeps working.
// Usage tracking is best-effort and entirely optional: it requires web
// analytics to be enabled, and analytics may be off (no statsConfig) or the
// query may error. In any of those cases `usage` is `undefined`, which the
// caller distinguishes from a resolved zero ({visits: 0}). `loading` and
// `error` are surfaced too so the caller can hide the count while loading or on
// error rather than rendering a misleading "0". Gating on the web-analytics
// setting here means every consumer (the share modal, opened from several
// places, and the analytics card) behaves consistently when analytics is off.
export const useGiftLinkUsage = ({postUuid, token, enabled = true}: {
postUuid?: string;
token?: string;
Expand All @@ -28,6 +33,9 @@ export const useGiftLinkUsage = ({postUuid, token, enabled = true}: {
const {data: configData} = useBrowseConfig();
const statsConfig = configData?.config?.stats as StatsConfig | undefined;

const {data: settingsData} = useBrowseSettings();
const webAnalyticsEnabled = getSettingValue<boolean>(settingsData?.settings ?? null, 'web_analytics_enabled') ?? false;

const params = useMemo(() => ({
site_uuid: statsConfig?.id || '',
post_uuid: postUuid || ''
Expand All @@ -37,21 +45,22 @@ export const useGiftLinkUsage = ({postUuid, token, enabled = true}: {
endpoint: 'api_gift_link_visits',
statsConfig: statsConfig || {id: ''},
params,
enabled: enabled && Boolean(statsConfig?.id) && Boolean(postUuid)
enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid)
});

const usage = useMemo<GiftLinkUsage | undefined>(() => {
// No token yet, the query is disabled/loading, or it errored → no data
// to show. Distinct from "ran and found zero", which yields {visits: 0}.
if (!token || error || !Array.isArray(data)) {
// Web analytics off, no token yet, the query is disabled/loading, or it
// errored → no data to show. Distinct from "ran and found zero", which
// yields {visits: 0}.
if (!webAnalyticsEnabled || !token || error || !Array.isArray(data)) {
return undefined;
}
const row = (data as unknown as GiftLinkVisitsRow[]).find(r => r.gift === token);
const row = (data as unknown as GiftLinkVisitsRow[]).find(r => r.gift_link === token);
return {
visits: Number(row?.visits) || 0,
views: Number(row?.views) || 0
};
}, [data, token, error]);
}, [data, token, error, webAnalyticsEnabled]);

return {usage, loading};
return {usage, loading, error};
};
22 changes: 14 additions & 8 deletions apps/posts/src/views/PostAnalytics/Overview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const Overview: React.FC = () => {
// minting) to scope the usage count to the current token, matching the modal.
const canManageGiftLink = useCanManageGiftLink(post);
const {token: giftToken} = useActiveGiftLink(postId, {enabled: canManageGiftLink});
const {usage: giftLinkUsage} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, enabled: canManageGiftLink});
const {usage: giftLinkUsage, loading: giftLinkUsageLoading, error: giftLinkUsageError} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, enabled: canManageGiftLink});
const [isGiftLinkOpen, setIsGiftLinkOpen] = useState(false);

// Calculate chart range based on days between today and post publication date
Expand Down Expand Up @@ -109,6 +109,9 @@ const Overview: React.FC = () => {
const showNewsletterSection = hasBeenEmailed(post as Post) && emailTrackOpensEnabled && emailTrackClicksEnabled;
const showWebSection = !post?.email_only && appSettings?.analytics.webAnalytics;
const showGrowthSection = appSettings?.analytics.membersTrackSources;
// The gift-link card only surfaces a web-analytics-derived visitor count, so
// it's hidden entirely when web analytics is disabled.
const showGiftLinkCard = Boolean(canManageGiftLink && post && appSettings?.analytics.webAnalytics);

// Redirect to Growth tab if this is a published-only post with web analytics disabled
// Only redirect if Growth section is available
Expand Down Expand Up @@ -147,10 +150,10 @@ const Overview: React.FC = () => {
post={post as Post}
/>
)}
{(showGrowthSection || (canManageGiftLink && post)) && (
{(showGrowthSection || showGiftLinkCard) && (
<div className='col-span-2 flex flex-col gap-6 lg:grid lg:grid-cols-3'>
{showGrowthSection && (
<Card className={`group overflow-hidden p-0 ${canManageGiftLink && post ? 'lg:col-span-2' : 'lg:col-span-3'}`} data-testid='growth'>
<Card className={`group overflow-hidden p-0 ${showGiftLinkCard ? 'lg:col-span-2' : 'lg:col-span-3'}`} data-testid='growth'>
<div className='relative flex items-center justify-between gap-6'>
<CardHeader>
<CardTitle className='flex items-center gap-1.5 text-lg'>
Expand Down Expand Up @@ -205,7 +208,7 @@ const Overview: React.FC = () => {
</CardContent>
</Card>
)}
{canManageGiftLink && post && (
{showGiftLinkCard && (
<Card className={`group/datalist overflow-hidden ${showGrowthSection ? 'lg:col-span-1' : 'lg:col-span-3'}`} data-testid='gift-link-card'>
<div className='relative flex items-center justify-between gap-6'>
<CardHeader>
Expand All @@ -223,26 +226,29 @@ const Overview: React.FC = () => {
Share
</Button>
</div>
{giftLinkUsage && (
{/* 0 is a valid count (no link yet, or a link with no visits),
so show it; only hide while loading or on error, where the
true value is unknown rather than zero. */}
{!giftLinkUsageLoading && !giftLinkUsageError && (
<CardContent className='flex flex-col gap-1'>
<span className='text-sm text-muted-foreground'>
Visitors
</span>
<span className='text-[2.2rem] leading-none font-semibold'>
{formatNumber(giftLinkUsage.visits)}
{formatNumber(giftLinkUsage?.visits ?? 0)}
</span>
</CardContent>
)}
</Card>
)}
</div>
)}
{!showWebSection && !showNewsletterSection && !showGrowthSection && !canManageGiftLink && (
{!showWebSection && !showNewsletterSection && !showGrowthSection && !showGiftLinkCard && (
<DisabledSourcesIndicator className='col-span-2 py-20' />
)}
</div>
</PostAnalyticsContent>
{canManageGiftLink && post && (
{showGiftLinkCard && (
<GiftLinkModal
key={postId}
open={isGiftLinkOpen}
Expand Down
89 changes: 89 additions & 0 deletions apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {renderHook} from '@testing-library/react';
import {useGiftLinkUsage} from '@src/hooks/use-gift-link-usage';

vi.mock('@tryghost/admin-x-framework', () => ({
useTinybirdQuery: vi.fn()
}));
vi.mock('@tryghost/admin-x-framework/api/config', () => ({
useBrowseConfig: vi.fn()
}));
vi.mock('@tryghost/admin-x-framework/api/settings', () => ({
useBrowseSettings: vi.fn(),
// Mirror the real helper: pull a setting's value out of the settings array.
getSettingValue: (settings: any[] | null, key: string) => settings?.find(s => s.key === key)?.value
}));

const TOKEN = 'gift_token_abc';

describe('useGiftLinkUsage', () => {
let mockUseTinybirdQuery: any;
let mockUseBrowseConfig: any;
let mockUseBrowseSettings: any;

beforeEach(async () => {
vi.clearAllMocks();
mockUseTinybirdQuery = vi.mocked(await import('@tryghost/admin-x-framework')).useTinybirdQuery;
mockUseBrowseConfig = vi.mocked(await import('@tryghost/admin-x-framework/api/config')).useBrowseConfig;
mockUseBrowseSettings = vi.mocked(await import('@tryghost/admin-x-framework/api/settings')).useBrowseSettings;

// Defaults: analytics configured + enabled, query resolved with no data.
mockUseBrowseConfig.mockReturnValue({data: {config: {stats: {id: 'site-uuid'}}}});
mockUseBrowseSettings.mockReturnValue({data: {settings: [{key: 'web_analytics_enabled', value: true}]}});
mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null});
});

it('returns usage for the row matching the token', () => {
mockUseTinybirdQuery.mockReturnValue({
data: [
{gift_link: 'other_token', visits: 9, views: 12},
{gift_link: TOKEN, visits: 3, views: 5}
],
loading: false,
error: null
});

const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN}));

expect(result.current.usage).toEqual({visits: 3, views: 5});
});

it('returns a resolved zero (not undefined) when no row matches the token', () => {
// "Ran and found nothing" is a real zero, distinct from "could not run".
mockUseTinybirdQuery.mockReturnValue({
data: [{gift_link: 'someone_elses_link', visits: 4, views: 6}],
loading: false,
error: null
});

const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN}));

expect(result.current.usage).toEqual({visits: 0, views: 0});
});

it('returns undefined usage when the query errors', () => {
mockUseTinybirdQuery.mockReturnValue({data: undefined, loading: false, error: new Error('boom')});

const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN}));

expect(result.current.usage).toBeUndefined();
expect(result.current.error).toBeInstanceOf(Error);
});

it('returns undefined usage when web analytics is disabled', () => {
mockUseBrowseSettings.mockReturnValue({data: {settings: [{key: 'web_analytics_enabled', value: false}]}});

const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN}));

expect(result.current.usage).toBeUndefined();
// The query must not run when analytics is off.
expect(mockUseTinybirdQuery.mock.calls[0][0].enabled).toBe(false);
});

it('returns undefined usage when there is no token yet', () => {
const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: undefined}));

expect(result.current.usage).toBeUndefined();
});
});
Loading