From 2c8fd5d0172423f92d8aa49ccd4b79285e0ab564 Mon Sep 17 00:00:00 2001 From: Junichi Hayashi <2093896+nahcnuj@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:19:37 +0900 Subject: [PATCH 1/2] feat: add X Ads download conversion tracker to DownloadAdDialog - Add X pixel event firing (twq('event', 'tw-ov0j6-ov0j9', {})) on download clicks - Inject X conversion tracking base code only for pages with download content (conditional on downloadAdPopup frontmatter, prod only) - Mirror gtagFn pattern with xPixelFn (dev logging + safe prod stub/queue) - Update tests to cover new X pixel path - Verified: non-download pages (index, most essays/diary) have no X tracker code; only download-link pages do - Non-visual change; no screenshots required per AGENTS.md --- app/client.ts | 20 +++++++++++++- app/components/DownloadAdDialog.test.ts | 35 +++++++++++++++++++++++++ app/components/DownloadAdDialog.tsx | 6 +++++ app/components/RootLayout.tsx | 14 ++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/client.ts b/app/client.ts index 2a5ee69e..77d2928b 100644 --- a/app/client.ts +++ b/app/client.ts @@ -22,6 +22,24 @@ const gtagFn: GtagFn = import.meta.env.PROD console.log('[gtag]', command, name, params) } +const xPixelFn = import.meta.env.PROD + ? () => { + const w = window as Window & { twq?: (...args: unknown[]) => void } + if (typeof w.twq === 'function') { + w.twq('event', 'tw-ov0j6-ov0j9', {}) + } else { + // Queue if the base pixel script has not yet defined twq (mirrors the X base IIFE) + const stub = (w.twq = w.twq || ((...args: unknown[]) => { + ;(stub as any).queue = (stub as any).queue || [] + ;(stub as any).queue.push(args) + })) + stub('event', 'tw-ov0j6-ov0j9', {}) + } + } + : () => { + console.log('[x-pixel]', 'event', 'tw-ov0j6-ov0j9', {}) + } + setupScrollDepthTracking({ gtagFn, addScrollListener: (handler) => window.addEventListener('scroll', handler, { passive: true }), @@ -83,4 +101,4 @@ setupMakamujoBannerTracking({ maxDelayMs: 500, }) -setupDownloadAdPopup({ whenReady, gtagFn }) +setupDownloadAdPopup({ whenReady, gtagFn, xPixelFn }) diff --git a/app/components/DownloadAdDialog.test.ts b/app/components/DownloadAdDialog.test.ts index f6cc2190..e266fa2d 100644 --- a/app/components/DownloadAdDialog.test.ts +++ b/app/components/DownloadAdDialog.test.ts @@ -97,6 +97,7 @@ function makeSetup( startDownload?: ReturnType hasDownloadUi?: () => boolean gtagFn?: ReturnType + xPixelFn?: ReturnType getPagePath?: () => string baseUrl?: string } = {}, @@ -119,6 +120,7 @@ function makeSetup( }, startDownload: startDownloadFn, gtagFn: opts.gtagFn, + xPixelFn: opts.xPixelFn, getPagePath: opts.getPagePath, baseUrl: opts.baseUrl, }) @@ -277,6 +279,39 @@ describe('setupDownloadAdPopup', () => { expect(gtagFn).not.toHaveBeenCalled() }) + it('fires X pixel conversion when xPixelFn is provided', () => { + const button = { + ...makeFakeButton('./test.pdf'), + textContent: 'ダウンロード', + } + const xPixelFn = vi.fn() + const { triggerClick } = makeSetup(button, { xPixelFn }) + + triggerClick() + + expect(xPixelFn).toHaveBeenCalledTimes(1) + }) + + it('fires both GA and X pixel on download when both fns provided', () => { + const button = makeFakeButton() + const gtagFn = vi.fn() + const xPixelFn = vi.fn() + const { triggerClick } = makeSetup(button, { gtagFn, xPixelFn }) + + triggerClick() + expect(gtagFn).toHaveBeenCalled() + expect(xPixelFn).toHaveBeenCalled() + }) + + it('does not fire X pixel when xPixelFn is omitted', () => { + const button = makeFakeButton() + const xPixelFn = vi.fn() + const { triggerClick } = makeSetup(button) + + triggerClick() + expect(xPixelFn).not.toHaveBeenCalled() + }) + it('opens cross-origin downloads in a new tab', () => { const button = makeFakeButton('https://example.com/file.zip', { sameOrigin: false, newTab: true }) const { startDownloadFn, triggerClick } = makeSetup(button) diff --git a/app/components/DownloadAdDialog.tsx b/app/components/DownloadAdDialog.tsx index 0690065a..130ad8bd 100644 --- a/app/components/DownloadAdDialog.tsx +++ b/app/components/DownloadAdDialog.tsx @@ -36,6 +36,8 @@ export interface DownloadAdPopupOptions { baseUrl?: string /** Sends GA4 `file_download` events when a download button is clicked. */ gtagFn?: GtagFn + /** Sends X Ads conversion event when a download button is clicked. */ + xPixelFn?: () => void /** @internal Test override for the current page path sent as `link_id`. */ getPagePath?: () => string } @@ -128,6 +130,7 @@ export function setupDownloadAdPopup({ hasDownloadUi = defaultHasDownloadUi, baseUrl, gtagFn, + xPixelFn, getPagePath = () => (typeof window !== 'undefined' ? window.location.pathname : ''), }: DownloadAdPopupOptions): void { whenReady(() => { @@ -150,6 +153,9 @@ export function setupDownloadAdPopup({ const linkText = button.textContent?.trim() ?? '' trackFileDownload(gtagFn, href, linkText, getPagePath()) } + if (xPixelFn) { + xPixelFn() + } startDownloadFn(href, download, newTab) }) }) diff --git a/app/components/RootLayout.tsx b/app/components/RootLayout.tsx index 31f1d22f..7dd038ea 100644 --- a/app/components/RootLayout.tsx +++ b/app/components/RootLayout.tsx @@ -153,6 +153,19 @@ const gtagSnippets = { `, } as const +const xPixelSnippets = { + body: html`\ + + + +`, +} as const + const OpenGraph = ({ url, image }: OpenGraphData) => url || image ? ( <> @@ -203,6 +216,7 @@ const Layout = (props: PropsWithChildren) => html` ${props.children} ${import.meta.env.PROD ? gtagSnippets.body : html`\n`}\ + ${import.meta.env.PROD && props.downloadAdPopup ? xPixelSnippets.body : html`\n`}\ ` From 5c60800f7c3af03757f94aa2db1fe8b0fe531b05 Mon Sep 17 00:00:00 2001 From: Junichi Hayashi <2093896+nahcnuj@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:25:14 +0900 Subject: [PATCH 2/2] fix: satisfy biome lint rules in xPixelFn (no any, no assign-in-expr) --- app/client.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/client.ts b/app/client.ts index 77d2928b..501841e5 100644 --- a/app/client.ts +++ b/app/client.ts @@ -22,17 +22,25 @@ const gtagFn: GtagFn = import.meta.env.PROD console.log('[gtag]', command, name, params) } +interface Twq { + (...args: unknown[]): void + queue?: unknown[][] +} + const xPixelFn = import.meta.env.PROD ? () => { - const w = window as Window & { twq?: (...args: unknown[]) => void } + const w = window as Window & { twq?: Twq } if (typeof w.twq === 'function') { w.twq('event', 'tw-ov0j6-ov0j9', {}) } else { - // Queue if the base pixel script has not yet defined twq (mirrors the X base IIFE) - const stub = (w.twq = w.twq || ((...args: unknown[]) => { - ;(stub as any).queue = (stub as any).queue || [] - ;(stub as any).queue.push(args) - })) + // Initialize stub + queue if the base X pixel script (uwt.js) has not loaded yet. + // Mirrors the queuing behavior of the X base IIFE so early calls are not lost. + const queue: unknown[][] = [] + const stub: Twq = (...args: unknown[]) => { + queue.push(args) + } + stub.queue = queue + w.twq = stub stub('event', 'tw-ov0j6-ov0j9', {}) } }