From 2afdb4dfe05cbe6cf50467d053ad7e714b1a7540 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 30 Jun 2026 17:35:13 +0200 Subject: [PATCH 1/6] Fixed gift-link usage count wiring and display ref https://linear.app/ghost/issue/BER-3746/integrate-gift-link-usage-tracking-with-analytics - the usage hook matched rows on `gift` but the Tinybird pipe returns `gift_link`, so the lookup never found its row and the count was always zero; matched on `gift_link` and added a unit test pinning the pipe->hook column contract, since the Tinybird response is untyped and a rename can't be caught by the compiler - gated the hook on web_analytics_enabled and hid the whole gift-link card when web analytics is off, since the count is web-analytics-derived and meaningless without it - surfaced `loading`/`error` from the hook so the card hides the count while loading or on error instead of rendering a misleading "0"; a resolved zero (no link yet, or a link with no visits) is a valid count and still shows --- apps/posts/src/hooks/use-gift-link-usage.ts | 33 ++++--- .../views/PostAnalytics/Overview/overview.tsx | 22 +++-- .../unit/hooks/use-gift-link-usage.test.tsx | 89 +++++++++++++++++++ 3 files changed, 124 insertions(+), 20 deletions(-) create mode 100644 apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index c4970737dc3..e9d46aba90a 100644 --- a/apps/posts/src/hooks/use-gift-link-usage.ts +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -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'; @@ -8,7 +9,7 @@ export interface GiftLinkUsage { } interface GiftLinkVisitsRow { - gift: string; + gift_link: string; visits: number | string; views: number | string; } @@ -16,10 +17,14 @@ interface GiftLinkVisitsRow { // 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; @@ -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(settingsData?.settings ?? null, 'web_analytics_enabled') ?? false; + const params = useMemo(() => ({ site_uuid: statsConfig?.id || '', post_uuid: postUuid || '' @@ -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(() => { - // 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}; }; diff --git a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx index 28cbd620dd1..c8c0869cd22 100644 --- a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx +++ b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx @@ -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 @@ -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 @@ -147,10 +150,10 @@ const Overview: React.FC = () => { post={post as Post} /> )} - {(showGrowthSection || (canManageGiftLink && post)) && ( + {(showGrowthSection || showGiftLinkCard) && (
{showGrowthSection && ( - +
@@ -205,7 +208,7 @@ const Overview: React.FC = () => { )} - {canManageGiftLink && post && ( + {showGiftLinkCard && (
@@ -223,13 +226,16 @@ const Overview: React.FC = () => { Share
- {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 && ( Visitors - {formatNumber(giftLinkUsage.visits)} + {formatNumber(giftLinkUsage?.visits ?? 0)} )} @@ -237,12 +243,12 @@ const Overview: React.FC = () => { )}
)} - {!showWebSection && !showNewsletterSection && !showGrowthSection && !canManageGiftLink && ( + {!showWebSection && !showNewsletterSection && !showGrowthSection && !showGiftLinkCard && ( )}
- {canManageGiftLink && post && ( + {showGiftLinkCard && ( ({ + 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(); + }); +}); From 09937c93608897d58d1bde5138bf106eaff5930c Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Jul 2026 09:52:52 +0200 Subject: [PATCH 2/6] Changed gift-link usage query to filter by the current token ref https://linear.app/tryghost/issue/BER-3746/ api_gift_link_visits now accepts an exact-match gift_link filter, so the hook requests only the current link's row instead of fetching every link on the post and matching client-side. Gated the query on the token being present and collapsed the client-side find to the single returned row. Updated the unit test: a no-reads token now resolves to an empty result set (a real zero), and the token is asserted to be pushed down as a query param. --- apps/posts/src/hooks/use-gift-link-usage.ts | 13 +++++++---- .../unit/hooks/use-gift-link-usage.test.tsx | 22 +++++++++---------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index e9d46aba90a..2ec5742d4e7 100644 --- a/apps/posts/src/hooks/use-gift-link-usage.ts +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -36,16 +36,20 @@ export const useGiftLinkUsage = ({postUuid, token, enabled = true}: { const {data: settingsData} = useBrowseSettings(); const webAnalyticsEnabled = getSettingValue(settingsData?.settings ?? null, 'web_analytics_enabled') ?? false; + // Filter to the current token server-side (api_gift_link_visits takes an + // exact-match gift_link param), so we fetch only this link's row instead of + // every link on the post. const params = useMemo(() => ({ site_uuid: statsConfig?.id || '', - post_uuid: postUuid || '' - }), [statsConfig?.id, postUuid]); + post_uuid: postUuid || '', + gift_link: token || '' + }), [statsConfig?.id, postUuid, token]); const {data, loading, error} = useTinybirdQuery({ endpoint: 'api_gift_link_visits', statsConfig: statsConfig || {id: ''}, params, - enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid) + enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid) && Boolean(token) }); const usage = useMemo(() => { @@ -55,7 +59,8 @@ export const useGiftLinkUsage = ({postUuid, token, enabled = true}: { if (!webAnalyticsEnabled || !token || error || !Array.isArray(data)) { return undefined; } - const row = (data as unknown as GiftLinkVisitsRow[]).find(r => r.gift_link === token); + // The query is scoped to this token, so there is at most one row. + const row = (data as unknown as GiftLinkVisitsRow[])[0]; return { visits: Number(row?.visits) || 0, views: Number(row?.views) || 0 diff --git a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx index c192d475d16..efe5525e923 100644 --- a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx +++ b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx @@ -34,12 +34,10 @@ describe('useGiftLinkUsage', () => { mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null}); }); - it('returns usage for the row matching the token', () => { + it('returns usage for the current token and scopes the query to it', () => { + // The pipe is filtered by token server-side, so it returns a single row. mockUseTinybirdQuery.mockReturnValue({ - data: [ - {gift_link: 'other_token', visits: 9, views: 12}, - {gift_link: TOKEN, visits: 3, views: 5} - ], + data: [{gift_link: TOKEN, visits: 3, views: 5}], loading: false, error: null }); @@ -47,15 +45,15 @@ describe('useGiftLinkUsage', () => { const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN})); expect(result.current.usage).toEqual({visits: 3, views: 5}); + // The token is pushed down to the query as an exact-match filter rather + // than fetching every link on the post and matching client-side. + expect(mockUseTinybirdQuery.mock.calls[0][0].params.gift_link).toBe(TOKEN); }); - 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 - }); + it('returns a resolved zero (not undefined) when the token has no reads', () => { + // Server-side filter matches nothing → no rows. "Ran and found nothing" + // is a real zero, distinct from "could not run". + mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null}); const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN})); From a4497432a7c913e701c3072590e28b184d94e4f3 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Jul 2026 12:09:57 +0200 Subject: [PATCH 3/6] Fixed gift-link usage card flashing a transient 0 while data loads ref https://linear.app/tryghost/issue/BER-3746 - the gift-link card's visibility is gated on the app-context settings, but its value came from useGiftLinkUsage, which disables the Tinybird query (and so reports loading:false) until config, settings, and the active-link token are all resolved - a disabled query is indistinguishable from "ran and found zero", so the card rendered 0 for a post that may already have an active link with traffic, until the token lookup resolved and the real count loaded in - fold the config/settings loading and the upstream active-link lookup's loading (passed in as tokenLoading) into the hook's `loading` so callers keep the count hidden while any prerequisite is still pending --- apps/posts/src/hooks/use-gift-link-usage.ts | 22 +++++++++++++--- .../views/PostAnalytics/Overview/overview.tsx | 4 +-- .../unit/hooks/use-gift-link-usage.test.tsx | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index 2ec5742d4e7..3742226ff9d 100644 --- a/apps/posts/src/hooks/use-gift-link-usage.ts +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -25,15 +25,24 @@ interface GiftLinkVisitsRow { // 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}: { +// +// The usage query is disabled until every prerequisite is in place (config, +// settings, and the token from the upstream active-link lookup). A disabled +// query reports `loading: false`, which would otherwise be indistinguishable +// from "ran and found zero". `tokenLoading` lets the caller pass in the +// active-link lookup's own loading state; combined with the config/settings +// reads here, `loading` stays true while any prerequisite is still resolving, +// so callers hide the count instead of flashing a transient "0". +export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, enabled = true}: { postUuid?: string; token?: string; + tokenLoading?: boolean; enabled?: boolean; }) => { - const {data: configData} = useBrowseConfig(); + const {data: configData, isLoading: configLoading} = useBrowseConfig(); const statsConfig = configData?.config?.stats as StatsConfig | undefined; - const {data: settingsData} = useBrowseSettings(); + const {data: settingsData, isLoading: settingsLoading} = useBrowseSettings(); const webAnalyticsEnabled = getSettingValue(settingsData?.settings ?? null, 'web_analytics_enabled') ?? false; // Filter to the current token server-side (api_gift_link_visits takes an @@ -45,13 +54,18 @@ export const useGiftLinkUsage = ({postUuid, token, enabled = true}: { gift_link: token || '' }), [statsConfig?.id, postUuid, token]); - const {data, loading, error} = useTinybirdQuery({ + const {data, loading: queryLoading, error} = useTinybirdQuery({ endpoint: 'api_gift_link_visits', statsConfig: statsConfig || {id: ''}, params, enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid) && Boolean(token) }); + // Treat a still-resolving prerequisite as loading, not as a resolved zero: + // the query is disabled (loading:false) until config, settings, and the + // token are all ready, so fold those pending states into `loading`. + const loading = queryLoading || configLoading || settingsLoading || tokenLoading; + const usage = useMemo(() => { // 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 diff --git a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx index c8c0869cd22..299eec62400 100644 --- a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx +++ b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx @@ -31,8 +31,8 @@ const Overview: React.FC = () => { // Gift link card: only for eligible posts. Read the active link (without // 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, loading: giftLinkUsageLoading, error: giftLinkUsageError} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, enabled: canManageGiftLink}); + const {token: giftToken, isLoading: giftTokenLoading} = useActiveGiftLink(postId, {enabled: canManageGiftLink}); + const {usage: giftLinkUsage, loading: giftLinkUsageLoading, error: giftLinkUsageError} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, tokenLoading: giftTokenLoading, enabled: canManageGiftLink}); const [isGiftLinkOpen, setIsGiftLinkOpen] = useState(false); // Calculate chart range based on days between today and post publication date diff --git a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx index efe5525e923..e45ec92076d 100644 --- a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx +++ b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx @@ -84,4 +84,30 @@ describe('useGiftLinkUsage', () => { expect(result.current.usage).toBeUndefined(); }); + + it('reports loading while the active gift-link lookup is still resolving', () => { + // The active-link lookup is in flight, so there is no token yet and the + // usage query is disabled (loading:false). Without threading that state + // through, the caller would treat the missing usage as a resolved 0 and + // flash "0" for a post that may already have an active link with traffic. + mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null}); + + const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: undefined, tokenLoading: true})); + + expect(result.current.loading).toBe(true); + expect(result.current.usage).toBeUndefined(); + }); + + it('reports loading while the settings prerequisite is still resolving', () => { + // Card visibility is driven off a separate settings read, so the card can + // be shown before this hook's useBrowseSettings resolves. Report loading + // so the count stays hidden rather than falling through to a resolved 0. + mockUseBrowseSettings.mockReturnValue({data: undefined, isLoading: true}); + mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null}); + + const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: TOKEN})); + + expect(result.current.loading).toBe(true); + expect(result.current.usage).toBeUndefined(); + }); }); From 418e941659271d0f1a257530c2c04fa916195095 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Jul 2026 13:24:52 +0200 Subject: [PATCH 4/6] Cleaned up gift-link usage hook comments to the load-bearing contract ref https://linear.app/tryghost/issue/BER-3746 - condensed the header and inline comments to what future readers need (the undefined-vs-resolved-zero contract and why loading folds in prerequisites), dropping incidental detail and fix-narrative - no behaviour change; tests unchanged in substance, only their comments --- apps/posts/src/hooks/use-gift-link-usage.ts | 38 +++++++------------ .../unit/hooks/use-gift-link-usage.test.tsx | 9 +---- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index 3742226ff9d..b8a9c760719 100644 --- a/apps/posts/src/hooks/use-gift-link-usage.ts +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -14,25 +14,18 @@ interface GiftLinkVisitsRow { 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. +// Reads gift-link usage (visits/views) from the web-analytics pipeline, keyed +// on the link token via api_gift_link_visits' exact-match filter. // -// 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. -// -// The usage query is disabled until every prerequisite is in place (config, -// settings, and the token from the upstream active-link lookup). A disabled -// query reports `loading: false`, which would otherwise be indistinguishable -// from "ran and found zero". `tokenLoading` lets the caller pass in the -// active-link lookup's own loading state; combined with the config/settings -// reads here, `loading` stays true while any prerequisite is still resolving, -// so callers hide the count instead of flashing a transient "0". +// Two states the caller must not confuse: +// - `usage` is `undefined` when there's nothing to show — analytics off, no +// token, or the query errored — so the caller hides the count. +// - `usage` is `{visits: 0, ...}` only when the query actually ran and +// found zero. +// `loading` covers every prerequisite (config, settings, and the upstream +// active-link lookup passed in as `tokenLoading`), not just the Tinybird query: +// a disabled query reports `loading: false`, which is otherwise indistinguishable +// from a resolved zero and would flash "0" while a link's token is still loading. export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, enabled = true}: { postUuid?: string; token?: string; @@ -61,15 +54,12 @@ export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, enabled enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid) && Boolean(token) }); - // Treat a still-resolving prerequisite as loading, not as a resolved zero: - // the query is disabled (loading:false) until config, settings, and the - // token are all ready, so fold those pending states into `loading`. + // Fold pending prerequisites into loading (a disabled query reads false). const loading = queryLoading || configLoading || settingsLoading || tokenLoading; const usage = useMemo(() => { - // 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}. + // Nothing to show (analytics off, no token, error, or query not run) → + // undefined, distinct from a resolved {visits: 0}. if (!webAnalyticsEnabled || !token || error || !Array.isArray(data)) { return undefined; } diff --git a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx index e45ec92076d..9902f9874b3 100644 --- a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx +++ b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx @@ -86,10 +86,8 @@ describe('useGiftLinkUsage', () => { }); it('reports loading while the active gift-link lookup is still resolving', () => { - // The active-link lookup is in flight, so there is no token yet and the - // usage query is disabled (loading:false). Without threading that state - // through, the caller would treat the missing usage as a resolved 0 and - // flash "0" for a post that may already have an active link with traffic. + // No token yet, so the usage query is disabled (loading:false). The hook + // must still report loading so callers hide the count rather than 0. mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null}); const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: undefined, tokenLoading: true})); @@ -99,9 +97,6 @@ describe('useGiftLinkUsage', () => { }); it('reports loading while the settings prerequisite is still resolving', () => { - // Card visibility is driven off a separate settings read, so the card can - // be shown before this hook's useBrowseSettings resolves. Report loading - // so the count stays hidden rather than falling through to a resolved 0. mockUseBrowseSettings.mockReturnValue({data: undefined, isLoading: true}); mockUseTinybirdQuery.mockReturnValue({data: [], loading: false, error: null}); From 37c63c8ce12f4687f5378598b5a47ca7b749ff77 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Jul 2026 15:10:13 +0200 Subject: [PATCH 5/6] Fixed gift-link usage card showing 0 when the active-link lookup fails ref https://linear.app/tryghost/issue/BER-3746 - the earlier fix hid the count while the active-link lookup was loading, but not when it errored: on a failed lookup there is no token, so the usage query stays disabled (error:null) and the card fell through to a resolved 0 - thread the lookup's error into useGiftLinkUsage as tokenError alongside tokenLoading, folding it into the returned error so the card hides the count on failure instead of showing a misleading 0 --- apps/posts/src/hooks/use-gift-link-usage.ts | 20 ++++++++++++------- .../views/PostAnalytics/Overview/overview.tsx | 4 ++-- .../unit/hooks/use-gift-link-usage.test.tsx | 12 +++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index b8a9c760719..b681448b03d 100644 --- a/apps/posts/src/hooks/use-gift-link-usage.ts +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -22,14 +22,17 @@ interface GiftLinkVisitsRow { // token, or the query errored — so the caller hides the count. // - `usage` is `{visits: 0, ...}` only when the query actually ran and // found zero. -// `loading` covers every prerequisite (config, settings, and the upstream -// active-link lookup passed in as `tokenLoading`), not just the Tinybird query: -// a disabled query reports `loading: false`, which is otherwise indistinguishable -// from a resolved zero and would flash "0" while a link's token is still loading. -export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, enabled = true}: { +// `loading` and `error` cover every prerequisite, not just the Tinybird query: +// the token comes from a separate active-link lookup (passed in as `tokenLoading` +// / `tokenError`), and until it resolves the query is disabled and reports +// loading:false / error:null — otherwise indistinguishable from a resolved zero, +// so the card would flash "0" while a link's token is still loading or after the +// lookup fails. +export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, tokenError = null, enabled = true}: { postUuid?: string; token?: string; tokenLoading?: boolean; + tokenError?: unknown; enabled?: boolean; }) => { const {data: configData, isLoading: configLoading} = useBrowseConfig(); @@ -47,15 +50,18 @@ export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, enabled gift_link: token || '' }), [statsConfig?.id, postUuid, token]); - const {data, loading: queryLoading, error} = useTinybirdQuery({ + const {data, loading: queryLoading, error: queryError} = useTinybirdQuery({ endpoint: 'api_gift_link_visits', statsConfig: statsConfig || {id: ''}, params, enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid) && Boolean(token) }); - // Fold pending prerequisites into loading (a disabled query reads false). + // The token comes from a separate upstream lookup, so fold its pending and + // failed states in too: a disabled query reads loading:false / error:null, + // which the caller would otherwise treat as a resolved zero. const loading = queryLoading || configLoading || settingsLoading || tokenLoading; + const error = queryError || tokenError || null; const usage = useMemo(() => { // Nothing to show (analytics off, no token, error, or query not run) → diff --git a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx index 299eec62400..11549e8a114 100644 --- a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx +++ b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx @@ -31,8 +31,8 @@ const Overview: React.FC = () => { // Gift link card: only for eligible posts. Read the active link (without // minting) to scope the usage count to the current token, matching the modal. const canManageGiftLink = useCanManageGiftLink(post); - const {token: giftToken, isLoading: giftTokenLoading} = useActiveGiftLink(postId, {enabled: canManageGiftLink}); - const {usage: giftLinkUsage, loading: giftLinkUsageLoading, error: giftLinkUsageError} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, tokenLoading: giftTokenLoading, enabled: canManageGiftLink}); + const {token: giftToken, isLoading: giftTokenLoading, error: giftTokenError} = useActiveGiftLink(postId, {enabled: canManageGiftLink}); + const {usage: giftLinkUsage, loading: giftLinkUsageLoading, error: giftLinkUsageError} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, tokenLoading: giftTokenLoading, tokenError: giftTokenError, enabled: canManageGiftLink}); const [isGiftLinkOpen, setIsGiftLinkOpen] = useState(false); // Calculate chart range based on days between today and post publication date diff --git a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx index 9902f9874b3..164eea0caae 100644 --- a/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx +++ b/apps/posts/test/unit/hooks/use-gift-link-usage.test.tsx @@ -105,4 +105,16 @@ describe('useGiftLinkUsage', () => { expect(result.current.loading).toBe(true); expect(result.current.usage).toBeUndefined(); }); + + it('surfaces an active gift-link lookup error so the count stays hidden', () => { + // The lookup failed, so there is no token and the usage query is disabled + // (no error of its own). Surface the lookup error rather than letting the + // caller treat missing usage as a resolved 0. + mockUseTinybirdQuery.mockReturnValue({data: undefined, loading: false, error: null}); + + const {result} = renderHook(() => useGiftLinkUsage({postUuid: 'post-uuid', token: undefined, tokenError: new Error('boom')})); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.usage).toBeUndefined(); + }); }); From 25a7bb043fbe6b5b77d52687071d1635c32ca558 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Jul 2026 15:28:02 +0200 Subject: [PATCH 6/6] Removed self-evident comments from the gift-link usage wiring ref https://linear.app/tryghost/issue/BER-3746 - dropped three comments that restated the code they sat above (the token filter param, the card's web-analytics gate, and the render guard) --- apps/posts/src/hooks/use-gift-link-usage.ts | 3 --- apps/posts/src/views/PostAnalytics/Overview/overview.tsx | 5 ----- 2 files changed, 8 deletions(-) diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index b681448b03d..88262bf59f5 100644 --- a/apps/posts/src/hooks/use-gift-link-usage.ts +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -41,9 +41,6 @@ export const useGiftLinkUsage = ({postUuid, token, tokenLoading = false, tokenEr const {data: settingsData, isLoading: settingsLoading} = useBrowseSettings(); const webAnalyticsEnabled = getSettingValue(settingsData?.settings ?? null, 'web_analytics_enabled') ?? false; - // Filter to the current token server-side (api_gift_link_visits takes an - // exact-match gift_link param), so we fetch only this link's row instead of - // every link on the post. const params = useMemo(() => ({ site_uuid: statsConfig?.id || '', post_uuid: postUuid || '', diff --git a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx index 11549e8a114..e72d103a83a 100644 --- a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx +++ b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx @@ -109,8 +109,6 @@ 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 @@ -226,9 +224,6 @@ const Overview: React.FC = () => { Share - {/* 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 && (