From bd86772d111ecf1349eded0f262f4f8b0b4c284d Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 25 Jun 2026 13:15:11 +0100 Subject: [PATCH] Added Gift source attribution for gift-link signups no ref - gift-link visits (`{post_url}?gift={token}`) unlock a single gated post; when the reader signs up they're attributed to whatever channel the link was shared through (usually Direct), so gift-derived signups are invisible as a distinct source in the Growth tab - gift links leave no server-side trace tying a signup to the gift (unlike gift subscriptions, which set member.status='gift'): the gift-links service only validates tokens and the `_gift` token is surfaced to Tinybird for visit analytics only - so at signup the gift signal exists solely as the `?gift` param on the unlocked post's URL in the browser - the history recorder ignores that param (it only reads ref/source/utm), so tag a `?gift` visit with referrer source 'Gift' (medium 'gift') there; it then flows through the normal referrer pipeline into members_created_events.referrer_source. The real referrer url is kept and an explicit ref/source/utm on the link still wins - a valid token renders in place (invalid tokens are 301-stripped pre-render), so a `?gift` present when the recorder runs is always a genuine gift view --- .../member-attribution/member-attribution.js | 12 ++++++- .../frontend/src/utils/url-attribution.js | 32 ++++++++++++++++- .../unit/frontend/src/url-attribution.test.js | 36 +++++++++++++++++-- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/ghost/core/core/frontend/src/member-attribution/member-attribution.js b/ghost/core/core/frontend/src/member-attribution/member-attribution.js index b887becad5f..ce62b36d675 100644 --- a/ghost/core/core/frontend/src/member-attribution/member-attribution.js +++ b/ghost/core/core/frontend/src/member-attribution/member-attribution.js @@ -3,6 +3,7 @@ const urlAttribution = require('../utils/url-attribution'); const parseReferrerData = urlAttribution.parseReferrerData; const getReferrer = urlAttribution.getReferrer; +const getGiftReferrer = urlAttribution.getGiftReferrer; // Location where we want to store the history in sessionStorage const STORAGE_KEY = 'ghost-history'; @@ -99,7 +100,16 @@ const LIMIT = 15; utmTerm: referrerData.utmTerm, utmContent: referrerData.utmContent }; - + + // Tag gift-link visits so gift-derived signups are attributed to the gift + // rather than the channel the link was shared through. The real referrer + // url is kept; only the source/medium are overridden. + const giftReferrer = getGiftReferrer(window.location.href, referrerData); + if (giftReferrer) { + attributionData.referrerSource = giftReferrer.source; + attributionData.referrerMedium = giftReferrer.medium; + } + // Use the getReferrer helper to handle same-domain referrer filtering // This will return null if the referrer is from the same domain let referrerUrl; diff --git a/ghost/core/core/frontend/src/utils/url-attribution.js b/ghost/core/core/frontend/src/utils/url-attribution.js index 925e334cbf6..39cc57bf247 100644 --- a/ghost/core/core/frontend/src/utils/url-attribution.js +++ b/ghost/core/core/frontend/src/utils/url-attribution.js @@ -95,11 +95,41 @@ function selectPrimaryReferrer(referrerData) { /** * One-step function to get the final referrer from a URL - * + * * @param {string} [url] - URL to parse (defaults to current URL) * @returns {string|null} Final referrer value */ export function getReferrer(url) { const referrerData = parseReferrerData(url); return selectPrimaryReferrer(referrerData); +} + +/** + * Source/medium to attribute a gift-link visit (`?gift=token`) to, so that + * gift-derived signups appear in member attribution instead of being credited + * to whatever channel the link happened to be shared through (usually Direct). + * + * Returns null when the URL isn't a gift link, or when it already carries an + * explicit ref/source/utm_source the site owner set — that wins over the gift. + * Note: an invalid gift token is stripped server-side via a 301 before the page + * renders, so a `?gift` param present at render time is always a valid gift. + * + * @param {string} url - The URL to inspect (the current page URL) + * @param {AttributionData} referrerData - Already-parsed data for the same URL + * @returns {{source: string, medium: string}|null} + */ +export function getGiftReferrer(url, referrerData) { + if (referrerData && referrerData.source) { + return null; + } + + try { + if (new URL(url).searchParams.has('gift')) { + return {source: 'Gift', medium: 'gift'}; + } + } catch (e) { + // Malformed URL - not a gift link + } + + return null; } \ No newline at end of file diff --git a/ghost/core/test/unit/frontend/src/url-attribution.test.js b/ghost/core/test/unit/frontend/src/url-attribution.test.js index e99bf2ccd45..a6d223f13c3 100644 --- a/ghost/core/test/unit/frontend/src/url-attribution.test.js +++ b/ghost/core/test/unit/frontend/src/url-attribution.test.js @@ -6,7 +6,8 @@ const {JSDOM} = require('jsdom'); // Use path relative to test file const { parseReferrerData, - getReferrer + getReferrer, + getGiftReferrer } = require('../../../../core/frontend/src/utils/url-attribution'); describe('URL Attribution Utils', function () { @@ -157,10 +158,41 @@ describe('URL Attribution Utils', function () { const result = getReferrer('https://example.com/?ref=newsletter'); assert.equal(result, 'newsletter'); }); - + it('should return null for same-domain referrers', function () { const result = getReferrer('https://example.com/?ref=https://example.com/page'); assert.equal(result, null); }); }); + + describe('getGiftReferrer', function () { + it('returns the Gift source/medium for a gift link', function () { + const url = 'https://example.com/my-post/?gift=token123'; + const result = getGiftReferrer(url, parseReferrerData(url)); + assert.deepEqual(result, {source: 'Gift', medium: 'gift'}); + }); + + it('returns null when there is no gift param', function () { + const url = 'https://example.com/my-post/'; + const result = getGiftReferrer(url, parseReferrerData(url)); + assert.equal(result, null); + }); + + it('defers to an explicit ref/source/utm on the gift link', function () { + const url = 'https://example.com/my-post/?gift=token123&ref=newsletter'; + const result = getGiftReferrer(url, parseReferrerData(url)); + assert.equal(result, null); + }); + + it('defers to an explicit utm_source on the gift link', function () { + const url = 'https://example.com/my-post/?gift=token123&utm_source=twitter'; + const result = getGiftReferrer(url, parseReferrerData(url)); + assert.equal(result, null); + }); + + it('returns null for a malformed URL', function () { + const result = getGiftReferrer('not a url', {source: null}); + assert.equal(result, null); + }); + }); }); \ No newline at end of file