diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts index c4970737dc3..88262bf59f5 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,50 +9,70 @@ 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. +// 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: 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. -export const useGiftLinkUsage = ({postUuid, token, enabled = true}: { +// 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` 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} = useBrowseConfig(); + const {data: configData, isLoading: configLoading} = useBrowseConfig(); const statsConfig = configData?.config?.stats as StatsConfig | undefined; + const {data: settingsData, isLoading: settingsLoading} = useBrowseSettings(); + const webAnalyticsEnabled = getSettingValue(settingsData?.settings ?? null, 'web_analytics_enabled') ?? false; + 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({ + const {data, loading: queryLoading, error: queryError} = useTinybirdQuery({ endpoint: 'api_gift_link_visits', statsConfig: statsConfig || {id: ''}, params, - enabled: enabled && Boolean(statsConfig?.id) && Boolean(postUuid) + enabled: enabled && webAnalyticsEnabled && Boolean(statsConfig?.id) && Boolean(postUuid) && Boolean(token) }); + // 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(() => { - // 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)) { + // 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; } - const row = (data as unknown as GiftLinkVisitsRow[]).find(r => r.gift === 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 }; - }, [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..e72d103a83a 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} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, 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 @@ -109,6 +109,7 @@ 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; + 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 +148,10 @@ const Overview: React.FC = () => { post={post as Post} /> )} - {(showGrowthSection || (canManageGiftLink && post)) && ( + {(showGrowthSection || showGiftLinkCard) && (
{showGrowthSection && ( - +
@@ -205,7 +206,7 @@ const Overview: React.FC = () => { )} - {canManageGiftLink && post && ( + {showGiftLinkCard && (
@@ -223,13 +224,13 @@ const Overview: React.FC = () => { Share
- {giftLinkUsage && ( + {!giftLinkUsageLoading && !giftLinkUsageError && ( Visitors - {formatNumber(giftLinkUsage.visits)} + {formatNumber(giftLinkUsage?.visits ?? 0)} )} @@ -237,12 +238,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 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: 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}); + // 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 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})); + + 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(); + }); + + it('reports loading while the active gift-link lookup is still resolving', () => { + // 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})); + + expect(result.current.loading).toBe(true); + expect(result.current.usage).toBeUndefined(); + }); + + it('reports loading while the settings prerequisite is still resolving', () => { + 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(); + }); + + 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(); + }); +});