Skip to content

Commit 5f65bc8

Browse files
ScarletttMoonScarlett Huang
authored andcommitted
feat(landing): first-touch attribution cookie for source→payment (#4820)
* feat(landing): first-touch attribution cookie for source->payment Capture utm_* + external referrer on landing into a first-touch od_attr cookie on the open-design.ai domain, so the same-origin vela checkout page (open-design.ai/cloud/*) can read it back and attribute payment to the originating traffic source. Forward-only; first-touch wins; sets nothing for true direct traffic. Companion vela PR reads it into billing_payment_result. * fix(landing): mount attribution cookie on bespoke marketing heads blog/{index,[slug]}, tutorials/{index,[slug]}, stories/{index,ikigai-one} have standalone <head> blocks that don't use sub-page-layout, so they only had GoogleAnalytics. Add AttributionCookie to each so utm/referrer landing on those marketing entry points (e.g. /blog/...?utm_source=) is captured instead of resolving to direct at checkout. Addresses PR review coverage gap. * refactor(landing): bundle head analytics into shared SiteAnalytics Replace the per-page GoogleAnalytics / PostHogAnalytics / AttributionCookie wiring with a single <SiteAnalytics /> mount used by every landing template (sub-page-layout, home, and the bespoke blog/tutorials/stories heads). This makes it structurally impossible for a marketing entry point to silently miss a tracker — the cause of the attribution-cookie coverage gap. Intentional coverage change: the bespoke blog/tutorials/stories pages previously had ONLY GoogleAnalytics; they now also get PostHog (they were already missing it). Supersedes the per-page cookie mounts from the previous commit. --------- Co-authored-by: Scarlett Huang <scarletthuang@Scarletts-MacBook-Air.local>
1 parent ce063c8 commit 5f65bc8

10 files changed

Lines changed: 98 additions & 20 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
/*
3+
* First-touch acquisition cookie for cross-app payment attribution.
4+
*
5+
* The marketing site (open-design.ai) and the vela checkout/wallet page
6+
* (served same-origin at open-design.ai/cloud/*) share the open-design.ai
7+
* domain. This captures utm_* + the external referrer on landing and stores a
8+
* FIRST-TOUCH cookie (`od_attr`) so the checkout page can read it back and
9+
* attribute the payment to the originating traffic source — closing the
10+
* "external source -> payment" gap (per-channel/per-campaign revenue).
11+
*
12+
* - Forward-only: only affects traffic after this ships.
13+
* - First-touch wins: never overwritten, so the original source survives
14+
* later internal navigation.
15+
* - Falls back to the referrer host when no utm is present, so organic
16+
* search (google/bing) is attributed too, not just tagged links.
17+
* - Sets nothing for true direct traffic (no utm, no external referrer).
18+
*
19+
* Injected alongside PostHog on every page. No PostHog dependency.
20+
*/
21+
---
22+
<script is:inline>
23+
(function () {
24+
try {
25+
var COOKIE = 'od_attr';
26+
// First-touch: if we already captured a source, keep it.
27+
if (document.cookie.indexOf(COOKIE + '=') !== -1) return;
28+
29+
var qs = new URLSearchParams(window.location.search);
30+
var get = function (k) { var v = qs.get(k); return v ? String(v).slice(0, 120) : ''; };
31+
var utmSource = get('utm_source');
32+
33+
var ref = '';
34+
try {
35+
if (document.referrer) {
36+
var rh = new URL(document.referrer).hostname;
37+
if (rh && rh.indexOf('open-design') === -1) ref = rh.slice(0, 120);
38+
}
39+
} catch (e) {}
40+
41+
// Nothing to attribute (direct, no utm, no external referrer) -> skip.
42+
if (!utmSource && !ref) return;
43+
44+
var data = {
45+
s: utmSource, m: get('utm_medium'), c: get('utm_campaign'),
46+
ct: get('utm_content'), tm: get('utm_term'),
47+
ref: ref, lp: (location.pathname || '/').slice(0, 80), t: Date.now()
48+
};
49+
50+
// 30-day first-touch window. Use the apex domain in production so the
51+
// same-origin checkout page (open-design.ai/cloud/*) can read it; on
52+
// preview/dev hosts fall back to a host-only cookie so it still works.
53+
var host = location.hostname || '';
54+
var domainAttr = /(^|\.)open-design\.ai$/.test(host) ? ';domain=.open-design.ai' : '';
55+
var secure = location.protocol === 'https:' ? ';Secure' : '';
56+
document.cookie = COOKIE + '=' + encodeURIComponent(JSON.stringify(data)) +
57+
';path=/;max-age=' + (60 * 60 * 24 * 30) + domainAttr + ';SameSite=Lax' + secure;
58+
} catch (e) {}
59+
})();
60+
</script>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
/*
3+
* Unified head analytics for every landing-page template.
4+
*
5+
* Bundles the three head-level trackers — Google Analytics, PostHog product
6+
* analytics, and the first-touch attribution cookie — behind a single mount
7+
* point. Templates include `<SiteAnalytics />` instead of wiring each tracker
8+
* by hand, so a marketing entry point can no longer silently miss one. (Before
9+
* this, every bespoke `<head>` wired GoogleAnalytics individually and several
10+
* pages — blog / tutorials / stories — were missing PostHog and the attribution
11+
* cookie entirely; arriving there with `?utm_source=` was attributed as direct.)
12+
*
13+
* Each child self-derives its context (PostHog reads the route for page_name;
14+
* the attribution cookie reads utm/referrer from the URL), so no props needed.
15+
*/
16+
import GoogleAnalytics from './google-analytics.astro';
17+
import PostHogAnalytics from './posthog-analytics.astro';
18+
import AttributionCookie from './attribution-cookie.astro';
19+
---
20+
<GoogleAnalytics />
21+
<PostHogAnalytics />
22+
<AttributionCookie />

apps/landing-page/app/_components/sub-page-layout.astro

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ import '../sub-pages.css';
1919
import { createElement } from 'react';
2020
import { renderToStaticMarkup } from 'react-dom/server';
2121
import FaviconLinks from './favicon-links.astro';
22-
import GoogleAnalytics from './google-analytics.astro';
23-
import PostHogAnalytics from './posthog-analytics.astro';
22+
import SiteAnalytics from './site-analytics.astro';
2423
import ResourceHints from './resource-hints.astro';
2524
import HeaderEnhancer from './header-enhancer.astro';
2625
import { Header, type HeaderProps } from './header';
@@ -139,8 +138,7 @@ const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
139138
<FaviconLinks />
140139
<ResourceHints />
141140

142-
<GoogleAnalytics />
143-
<PostHogAnalytics />
141+
<SiteAnalytics />
144142

145143
<meta property="og:type" content="website" />
146144
<meta property="og:site_name" content="Open Design" />

apps/landing-page/app/pages/blog/[slug].astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { getCollection, render } from 'astro:content';
1010
import { createElement } from 'react';
1111
import { renderToStaticMarkup } from 'react-dom/server';
12-
import GoogleAnalytics from '../../_components/google-analytics.astro';
12+
import SiteAnalytics from '../../_components/site-analytics.astro';
1313
import HeaderEnhancer from '../../_components/header-enhancer.astro';
1414
import { Header, type HeaderProps } from '../../_components/header';
1515
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@@ -207,7 +207,7 @@ const author = getBlogAuthor((post.data as { author?: string }).author);
207207
category={post.data.category}
208208
/>
209209
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)} />
210-
<GoogleAnalytics />
210+
<SiteAnalytics />
211211
</head>
212212
<body>
213213
<div class='shell'>

apps/landing-page/app/pages/blog/index.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { getCollection } from 'astro:content';
1414
import { createElement } from 'react';
1515
import { renderToStaticMarkup } from 'react-dom/server';
16-
import GoogleAnalytics from '../../_components/google-analytics.astro';
16+
import SiteAnalytics from '../../_components/site-analytics.astro';
1717
import HeaderEnhancer from '../../_components/header-enhancer.astro';
1818
import { Header, type HeaderProps } from '../../_components/header';
1919
import LazyImg from '../../_components/lazy-img.astro';
@@ -182,7 +182,7 @@ const localizedPostField = (
182182
description={seoDescription}
183183
pathname={Astro.url.pathname}
184184
/>
185-
<GoogleAnalytics />
185+
<SiteAnalytics />
186186
</head>
187187
<body>
188188
<div class='shell'>

apps/landing-page/app/pages/index.astro

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { createRequire } from 'node:module';
66
import { createElement } from 'react';
77
import { renderToStaticMarkup } from 'react-dom/server';
88
import FaviconLinks from '../_components/favicon-links.astro';
9-
import GoogleAnalytics from '../_components/google-analytics.astro';
10-
import PostHogAnalytics from '../_components/posthog-analytics.astro';
9+
import SiteAnalytics from '../_components/site-analytics.astro';
1110
import ResourceHints from '../_components/resource-hints.astro';
1211
import LocaleSwitcherScript from '../_components/locale-switcher-script.astro';
1312
import PreciseLazyload from '../_components/precise-lazyload.astro';
@@ -164,8 +163,7 @@ const matterRuntime = readFileSync(
164163
<FaviconLinks />
165164
<ResourceHints />
166165

167-
<GoogleAnalytics />
168-
<PostHogAnalytics />
166+
<SiteAnalytics />
169167

170168
{/*
171169
* Hero LCP preload. Cloudflare Pages turns this <link rel="preload"> into

apps/landing-page/app/pages/stories/ikigai-one.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818
import { createElement } from 'react';
1919
import { renderToStaticMarkup } from 'react-dom/server';
20-
import GoogleAnalytics from '../../_components/google-analytics.astro';
20+
import SiteAnalytics from '../../_components/site-analytics.astro';
2121
import HeaderEnhancer from '../../_components/header-enhancer.astro';
2222
import { Header, type HeaderProps } from '../../_components/header';
2323
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@@ -95,7 +95,7 @@ const headerHtml = renderToStaticMarkup(
9595
image={ogImage}
9696
/>
9797
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)} />
98-
<GoogleAnalytics />
98+
<SiteAnalytics />
9999
</head>
100100
<body>
101101
<div class='shell'>

apps/landing-page/app/pages/stories/index.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
*/
1313
import { createElement } from 'react';
1414
import { renderToStaticMarkup } from 'react-dom/server';
15-
import GoogleAnalytics from '../../_components/google-analytics.astro';
15+
import SiteAnalytics from '../../_components/site-analytics.astro';
1616
import HeaderEnhancer from '../../_components/header-enhancer.astro';
1717
import { Header, type HeaderProps } from '../../_components/header';
1818
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@@ -98,7 +98,7 @@ const headerHtml = renderToStaticMarkup(
9898
image={ogImage}
9999
/>
100100
<script is:inline type='application/ld+json' set:html={JSON.stringify(collectionJsonLd)} />
101-
<GoogleAnalytics />
101+
<SiteAnalytics />
102102
</head>
103103
<body>
104104
<div class='shell'>

apps/landing-page/app/pages/tutorials/[slug].astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import { getCollection, render } from 'astro:content';
1212
import { createElement } from 'react';
1313
import { renderToStaticMarkup } from 'react-dom/server';
14-
import GoogleAnalytics from '../../_components/google-analytics.astro';
14+
import SiteAnalytics from '../../_components/site-analytics.astro';
1515
import HeaderEnhancer from '../../_components/header-enhancer.astro';
1616
import { Header, type HeaderProps } from '../../_components/header';
1717
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@@ -141,7 +141,7 @@ const tutorialCopy =
141141
datePublished={entry.data.date}
142142
category={tutorialCopy.category}
143143
/>
144-
<GoogleAnalytics />
144+
<SiteAnalytics />
145145
</head>
146146
<body>
147147
<div class='shell'>

apps/landing-page/app/pages/tutorials/index.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { getCollection } from 'astro:content';
1414
import { createElement } from 'react';
1515
import { renderToStaticMarkup } from 'react-dom/server';
16-
import GoogleAnalytics from '../../_components/google-analytics.astro';
16+
import SiteAnalytics from '../../_components/site-analytics.astro';
1717
import HeaderEnhancer from '../../_components/header-enhancer.astro';
1818
import { Header, type HeaderProps } from '../../_components/header';
1919
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@@ -127,7 +127,7 @@ const featuredCopy = featuredTutorial ? localizedTutorial(featuredTutorial) : un
127127
description={seoDescription}
128128
pathname={Astro.url.pathname}
129129
/>
130-
<GoogleAnalytics />
130+
<SiteAnalytics />
131131
</head>
132132
<body>
133133
<div class='shell'>

0 commit comments

Comments
 (0)