From c89172ea9d56531f72d6590449ff4104eff21f33 Mon Sep 17 00:00:00 2001 From: abi Date: Sun, 30 Aug 2026 16:42:59 +0100 Subject: [PATCH] feat: promote iPhone app across visualizer pages --- .env.example | 3 + docs/PRODUCT-EXPERIMENTS.md | 22 +++ scripts/tests/app-store-attribution.test.mjs | 54 ++++++ .../insomnia-page.tsx | 5 + .../4-7-8-breathing-timer/timer-page.tsx | 5 + .../box-breathing-app-page.tsx | 5 + src/app/(site-en)/breathe/pattern-page.tsx | 3 + .../breathing-app/breathing-app-page.tsx | 3 + .../breathing-visualizer/visualizer-page.tsx | 5 + src/app/(site-en)/for/use-case-page.tsx | 3 + src/app/(site-en)/home-page.tsx | 3 + src/app/(site-en)/resonance-guide-page.tsx | 5 + src/app/globals.css | 17 ++ src/components/app-store-promotion.tsx | 160 ++++++++++++++++++ src/lib/app-store-attribution.ts | 88 ++++++++++ 15 files changed, 381 insertions(+) create mode 100644 scripts/tests/app-store-attribution.test.mjs create mode 100644 src/components/app-store-promotion.tsx create mode 100644 src/lib/app-store-attribution.ts diff --git a/.env.example b/.env.example index b5229da..0268875 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ NEXT_PUBLIC_GEMINI_API_KEY= # Only shows during December and January NEXT_PUBLIC_HOLIDAY_BANNER=true +# App Store Connect provider token (optional, enables page-level campaign links) +NEXT_PUBLIC_APP_STORE_PROVIDER_TOKEN= + # Better Auth BETTER_AUTH_SECRET= # openssl rand -base64 32 BETTER_AUTH_URL=http://localhost:3000 # production: https://deepbreathingexercises.com diff --git a/docs/PRODUCT-EXPERIMENTS.md b/docs/PRODUCT-EXPERIMENTS.md index b9e17d2..bc8cf44 100644 --- a/docs/PRODUCT-EXPERIMENTS.md +++ b/docs/PRODUCT-EXPERIMENTS.md @@ -23,6 +23,7 @@ Reverse chronological. Legend: ✅ Success · ❌ Failed · ⚪ Inconclusive · | Date | Entry | Status | |------|-------|--------| +| 2026-08-29 | [App Store promotion rollout and acquisition instrumentation](#2026-08-29-app-store-promotion-rollout-and-acquisition-instrumentation) | 🔄 Implemented locally — deployment pending | | 2026-07-27 | [Restore mount-time GA4 event queue](#2026-07-27-restore-mount-time-ga4-event-queue) | 🌙 Implemented locally — unshipped Night Shift measurement repair | | 2026-07-20 | [Authenticated `/stats` reach instrumentation](#2026-07-20-authenticated-stats-reach-instrumentation) | 🔄 Implemented — PR [#53](https://github.com/Darkmatter-AI/deepbreathing/pull/53); first mature read 2026-07-29 | | 2026-07-12 | [TestFlight native-sheet and practice-identity pass](#2026-07-12-testflight-native-sheet-and-practice-identity-pass) | 🔄 Implemented locally — physical build validation pending | @@ -54,6 +55,27 @@ See also: [docs/FUNNEL-DASHBOARD.md](FUNNEL-DASHBOARD.md) for the current state, ## Active Experiments +### 2026-08-29: App Store promotion rollout and acquisition instrumentation + +**Observed baseline:** The website had no consistent native-app promotion across its visualizer pages and no dedicated event for App Store promotion exposure or intent. GA4 enhanced measurement may record generic outbound clicks, but it does not provide a reliable exposure denominator or distinguish the landing card from the visualizer-page strip. The dedicated baseline is therefore uninstrumented, not zero user interest. + +**Hypothesis:** A visually distinct App Store card immediately after the visualizer, plus a fuller card on `/breathing-app`, will make the better native experience discoverable after users have already received value. Recording views and clicks with placement, origin page, and allowlisted campaign context will reveal which pages and placements earn intent without collecting arbitrary query parameters or raw advertising click IDs. + +**Exact change:** Add the official App Store badge inside an Aurora-edge card on the homepage, 14 breathing-technique pages, 18 use-case pages, four resonance guides, and four dedicated visualizer/timer pages, covering 41 public English visualizer routes through shared templates. Add the larger landing treatment below the introduction on `/breathing-app`. Preserve immediate visualizer actions and exclude iframe embeds, internal tools, and localized routes until translated copy exists. Emit `app_store_promotion_view` once when at least half of the promotion enters the viewport and emit `app_store_click` on the App Store badge. Both events include `app_store_placement`, `origin_path`, the App Store link fields, allowlisted UTM values, and presence-only flags for advertising click IDs. Restrict emission to the production host. Register the event-scoped GA4 custom dimension `App Store placement`; use GA4's standard Page path dimension for origin reporting. Support App Store Connect campaign links when `NEXT_PUBLIC_APP_STORE_PROVIDER_TOKEN` is configured, with a stable page-specific campaign token of at most 30 characters. Add the metric to the read-only three-day channel review. + +**Pre-committed success criteria:** +- Instrumentation passes if the first mature three-day post-deploy window contains `app_store_promotion_view` on eligible pages, every recorded placement is `landing` or `strip`, App Store clicks resolve to `apps.apple.com`, Page path identifies the origin, and preview or localhost traffic produces no events. +- Reporting is usable when the automation can report view users, click users, and click users divided by view users for the same complete window, with placement and meaningful Page path breakdowns. +- Treat results below 20 promotion-view users as directional and wait for at least 100 promotion-view users for a product verdict. +- ✅ Success if exposed-user click-through rate is at least 5% at 100 promotion-view users and the matched `page_viewed_breathing` to `breathing_session_start` rate does not decline by more than 5 percentage points. +- ❌ Failed if exposed-user click-through rate is at most 2% at 100 promotion-view users, or the matched breathing-session start rate declines by more than 10 percentage points. +- 🟡 Mixed if click-through rate is between 2% and 5%, or strong intent is concentrated on only one placement or page family. Do not mark `app_store_click` as a key event without a separate product decision. +- Fail the instrumentation if eligible production pages have page views but no promotion views after the first mature window, if arbitrary query parameters or raw click IDs appear in event data, or if the event duplicates from a single exposure. + +**Measure-after:** First technical read immediately after an authorized production deployment. First data read after three complete days plus the normal GA4 maturity lag. + +**Status:** 🔄 Implemented locally and unshipped. The GA4 `App Store placement` custom dimension and three-day automation review are configured. The App Store Connect provider token is not configured, so Apple campaign-link attribution is not yet active. No deployment was performed. + ### 2026-07-27: Restore Mount-Time GA4 Event Queue **Observed baseline:** In matched mature production-host windows, `page_viewed_breathing` fell from 293 users on 2026-07-12–18 to 50 on 2026-07-19–25 while `breathing_session_start` rose from 80 to 124. The resulting 248% viewed-to-start ratio is impossible. The break begins immediately after the 2026-07-18 host-gating refactor moved GA initialization into `GoogleAnalyticsScript` behind a parent `useEffect`; `Resonance` emits `page_viewed_breathing` once from a child mount effect before `window.gtag` exists, while later interaction events still arrive. diff --git a/scripts/tests/app-store-attribution.test.mjs b/scripts/tests/app-store-attribution.test.mjs new file mode 100644 index 0000000..dec5b51 --- /dev/null +++ b/scripts/tests/app-store-attribution.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + APP_STORE_URL, + createAppleCampaignToken, + createAppStoreAnalyticsParams, + createAppStoreDestination, +} from "../../src/lib/app-store-attribution.ts"; + +test("captures allowlisted campaign context without copying arbitrary values", () => { + const currentUrl = new URL( + "https://deepbreathingexercises.com/breathe/box?utm_source=newsletter&utm_campaign=launch&gclid=private-click-id&email=person%40example.com", + ); + const params = createAppStoreAnalyticsParams({ + currentUrl, + destination: APP_STORE_URL, + variant: "strip", + }); + + assert.equal(params.origin_path, "/breathe/box"); + assert.equal(params.app_store_placement, "strip"); + assert.equal(params.utm_source, "newsletter"); + assert.equal(params.utm_campaign, "launch"); + assert.equal(params.has_gclid, true); + assert.equal("gclid" in params, false); + assert.equal("email" in params, false); +}); + +test("keeps the normal App Store URL until a provider token is configured", () => { + const currentUrl = new URL("https://deepbreathingexercises.com/"); + + assert.equal(createAppStoreDestination({ currentUrl }), APP_STORE_URL); + assert.equal( + createAppStoreDestination({ currentUrl, providerToken: "not-a-token" }), + APP_STORE_URL, + ); +}); + +test("builds a valid page-specific Apple campaign link with a provider token", () => { + const currentUrl = new URL( + "https://deepbreathingexercises.com/box-breathing-before-presentation", + ); + const destination = new URL( + createAppStoreDestination({ currentUrl, providerToken: "123456" }), + ); + const campaignToken = destination.searchParams.get("ct"); + + assert.equal(destination.searchParams.get("pt"), "123456"); + assert.equal(destination.searchParams.get("mt"), "8"); + assert.equal(campaignToken, createAppleCampaignToken(currentUrl.pathname)); + assert.ok(campaignToken); + assert.ok(campaignToken.length <= 30); +}); diff --git a/src/app/(site-en)/4-7-8-breathing-for-insomnia/insomnia-page.tsx b/src/app/(site-en)/4-7-8-breathing-for-insomnia/insomnia-page.tsx index 5204d2b..6846d1c 100644 --- a/src/app/(site-en)/4-7-8-breathing-for-insomnia/insomnia-page.tsx +++ b/src/app/(site-en)/4-7-8-breathing-for-insomnia/insomnia-page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { JsonLd } from "@/components/seo/json-ld"; import sourceContent from "@/i18n/content/bespoke/insomnia-4-7-8/source.json"; import type { @@ -161,6 +162,10 @@ export function InsomniaPage({
+ {!renderContext ? ( + + ) : null} +

{copy("updated")}

diff --git a/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx b/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx index 68609f8..3e8c246 100644 --- a/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx +++ b/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { ModeName } from "@/components/resonance/types"; import { JsonLd } from "@/components/seo/json-ld"; import sourceContent from "@/i18n/content/bespoke/timer-4-7-8/source.json"; @@ -190,6 +191,10 @@ export function TimerPage({
+ {!renderContext ? ( + + ) : null} +

{copy("updated")}

{copy("quickAnswer.title")}

diff --git a/src/app/(site-en)/box-breathing-app/box-breathing-app-page.tsx b/src/app/(site-en)/box-breathing-app/box-breathing-app-page.tsx index 0159bf1..cb6b219 100644 --- a/src/app/(site-en)/box-breathing-app/box-breathing-app-page.tsx +++ b/src/app/(site-en)/box-breathing-app/box-breathing-app-page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { JsonLd } from "@/components/seo/json-ld"; import sourceContent from "@/i18n/content/bespoke/rw03-app-pages/source/box-breathing-app.json"; import type { @@ -172,6 +173,10 @@ export function BoxBreathingAppPage({
+ {!renderContext ? ( + + ) : null} +

{copy("quickAnswer.title")} diff --git a/src/app/(site-en)/breathe/pattern-page.tsx b/src/app/(site-en)/breathe/pattern-page.tsx index 4de5038..14c7d14 100644 --- a/src/app/(site-en)/breathe/pattern-page.tsx +++ b/src/app/(site-en)/breathe/pattern-page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { FadingHeroTitle } from "@/components/breathe/fading-hero-title"; import { BREATHING_PATTERNS } from "@/components/resonance/constants"; import { JsonLd } from "@/components/seo/json-ld"; @@ -258,6 +259,8 @@ export function PatternPage({
+ {!renderContext ? : null} + {page.meta.dateModified && (

+ {!renderContext ? : null} +

diff --git a/src/app/(site-en)/breathing-visualizer/visualizer-page.tsx b/src/app/(site-en)/breathing-visualizer/visualizer-page.tsx index 434eb66..7745274 100644 --- a/src/app/(site-en)/breathing-visualizer/visualizer-page.tsx +++ b/src/app/(site-en)/breathing-visualizer/visualizer-page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { BREATHING_PATTERNS } from "@/components/resonance/constants"; import { ModeName } from "@/components/resonance/types"; import { JsonLd } from "@/components/seo/json-ld"; @@ -215,6 +216,10 @@ export function BreathingVisualizerPage({

+ {!renderContext ? ( + + ) : null} + {/* Voice search / Quick answer */}

diff --git a/src/app/(site-en)/for/use-case-page.tsx b/src/app/(site-en)/for/use-case-page.tsx index d154af5..8fd7b37 100644 --- a/src/app/(site-en)/for/use-case-page.tsx +++ b/src/app/(site-en)/for/use-case-page.tsx @@ -3,6 +3,7 @@ import React from "react"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { FadingHeroTitle } from "@/components/breathe/fading-hero-title"; import { BREATHING_PATTERNS } from "@/components/resonance/constants"; import { JsonLd } from "@/components/seo/json-ld"; @@ -375,6 +376,8 @@ export function UseCasePage({ : undefined } > + {!renderContext ? : null} + {/* Back to Holiday Hub link */} {isHolidayPage && (
diff --git a/src/app/(site-en)/home-page.tsx b/src/app/(site-en)/home-page.tsx index 2ed0f56..a905a59 100644 --- a/src/app/(site-en)/home-page.tsx +++ b/src/app/(site-en)/home-page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import type { ReactNode } from "react"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { FadingHeroTitle } from "@/components/breathe/fading-hero-title"; import { JsonLd } from "@/components/seo/json-ld"; import { LanguageSwitcherFooter } from "@/components/language-switcher-footer-lazy"; @@ -276,6 +277,8 @@ export function HomePage({

+ {!renderContext ? : null} +

{content.sections.precision.title} diff --git a/src/app/(site-en)/resonance-guide-page.tsx b/src/app/(site-en)/resonance-guide-page.tsx index 35b7092..8e2dba1 100644 --- a/src/app/(site-en)/resonance-guide-page.tsx +++ b/src/app/(site-en)/resonance-guide-page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AppStorePromotion } from "@/components/app-store-promotion"; import { ModeName } from "@/components/resonance/types"; import { JsonLd } from "@/components/seo/json-ld"; import type { @@ -474,6 +475,10 @@ export function ResonanceGuidePage({

+ {!renderContext ? ( + + ) : null} +

{content.updated}

{content.alert ? ( diff --git a/src/app/globals.css b/src/app/globals.css index 6af4844..4141e5d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -228,6 +228,23 @@ body[data-resonance-immersive="true"] { transition: box-shadow 250ms ease; } +.app-store-promotion { + border-color: transparent; + background: + linear-gradient(hsl(var(--card)), hsl(var(--card))) padding-box, + linear-gradient( + 110deg, + rgba(255, 145, 110, 0.95), + rgba(232, 50, 96, 0.9), + rgba(132, 105, 255, 0.85) + ) + border-box; + box-shadow: + -12px 0 38px rgba(255, 120, 92, 0.15), + 12px 0 38px rgba(132, 105, 255, 0.16), + 0 24px 60px hsl(var(--shadow) / 0.18); +} + .glow-card-inward { overflow: hidden; box-shadow: none; diff --git a/src/components/app-store-promotion.tsx b/src/components/app-store-promotion.tsx new file mode 100644 index 0000000..2aa7692 --- /dev/null +++ b/src/components/app-store-promotion.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useRef, type MouseEvent } from "react"; +import { usePathname } from "next/navigation"; + +import { + type AppStorePromotionVariant, + createAppStoreAnalyticsParams, + createAppStoreDestination, +} from "@/lib/app-store-attribution"; +import { PRODUCTION_HOSTNAMES } from "@/lib/analytics/google-analytics"; + +const APP_STORE_BADGE_URL = + "https://toolbox.marketingtools.apple.com/api/badges/download-on-the-app-store/black/en-us?size=250x83"; + +const APP_STORE_PROVIDER_TOKEN = + process.env.NEXT_PUBLIC_APP_STORE_PROVIDER_TOKEN; + +type AppStoreEventName = "app_store_click" | "app_store_promotion_view"; +type Gtag = ( + command: "event", + name: AppStoreEventName, + params: Record, +) => void; + +function getGtag() { + if (typeof window === "undefined") return undefined; + if (!PRODUCTION_HOSTNAMES.has(window.location.hostname)) return undefined; + + const gtag = (window as unknown as { gtag?: Gtag }).gtag; + return typeof gtag === "function" ? gtag : undefined; +} + +function trackAppStoreEvent( + name: AppStoreEventName, + variant: AppStorePromotionVariant, +) { + const gtag = getGtag(); + if (!gtag) return; + + const currentUrl = new URL(window.location.href); + const destination = createAppStoreDestination({ + currentUrl, + providerToken: APP_STORE_PROVIDER_TOKEN, + }); + gtag( + "event", + name, + createAppStoreAnalyticsParams({ currentUrl, destination, variant }), + ); +} + +const promotionCopy = { + landing: { + title: "A more focused practice, now on iPhone", + body: "A more focused way to practice, with full-screen sessions, every breathing mode, sound, haptics, and pacing controls made for your phone.", + }, + strip: { + title: "Now an iPhone app", + body: "Full-screen sessions with sound, haptics, and pacing controls, made for a more focused practice.", + }, +} satisfies Record< + AppStorePromotionVariant, + { title: string; body: string } +>; + +export function AppStorePromotion({ + className, + variant, +}: { + className?: string; + variant: AppStorePromotionVariant; +}) { + const copy = promotionCopy[variant]; + const isLanding = variant === "landing"; + const pathname = usePathname(); + const sectionRef = useRef(null); + const hasTrackedView = useRef(false); + const appStoreHref = createAppStoreDestination({ + currentUrl: new URL(pathname, "https://deepbreathingexercises.com"), + providerToken: APP_STORE_PROVIDER_TOKEN, + }); + + useEffect(() => { + hasTrackedView.current = false; + const section = sectionRef.current; + if (!section || !getGtag() || typeof IntersectionObserver === "undefined") { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting || hasTrackedView.current) return; + hasTrackedView.current = true; + trackAppStoreEvent("app_store_promotion_view", variant); + observer.disconnect(); + }, + { threshold: 0.5 }, + ); + + observer.observe(section); + return () => observer.disconnect(); + }, [pathname, variant]); + + const handleAppStoreClick = (event: MouseEvent) => { + const currentUrl = new URL(window.location.href); + event.currentTarget.href = createAppStoreDestination({ + currentUrl, + providerToken: APP_STORE_PROVIDER_TOKEN, + }); + if (!hasTrackedView.current) { + hasTrackedView.current = true; + trackAppStoreEvent("app_store_promotion_view", variant); + } + trackAppStoreEvent("app_store_click", variant); + }; + + return ( +
+
+

+ {copy.title} +

+

+ {copy.body} +

+
+ + + {/* Apple requires the official badge artwork to remain unmodified. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + Download on the App Store + +
+ ); +} diff --git a/src/lib/app-store-attribution.ts b/src/lib/app-store-attribution.ts new file mode 100644 index 0000000..3bd2078 --- /dev/null +++ b/src/lib/app-store-attribution.ts @@ -0,0 +1,88 @@ +export const APP_STORE_URL = + "https://apps.apple.com/us/app/deep-breathing-calm-sleep/id6786431781"; + +export type AppStorePromotionVariant = "landing" | "strip"; + +const CAMPAIGN_PARAMETER_NAMES = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", +] as const; + +const AD_CLICK_ID_NAMES = ["gclid", "dclid", "gbraid", "wbraid"] as const; + +function shortHash(value: string) { + let hash = 2166136261; + + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(36); +} + +export function createAppleCampaignToken(pathname: string) { + const readablePath = + pathname === "/" + ? "home" + : pathname + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + const readablePrefix = readablePath.slice(0, 18) || "page"; + + return `web_${readablePrefix}_${shortHash(pathname)}`.slice(0, 30); +} + +export function createAppStoreDestination({ + currentUrl, + providerToken, +}: { + currentUrl: URL; + providerToken?: string; +}) { + const destination = new URL(APP_STORE_URL); + if (!providerToken || !/^\d+$/.test(providerToken)) { + return destination.toString(); + } + + destination.searchParams.set("pt", providerToken); + destination.searchParams.set( + "ct", + createAppleCampaignToken(currentUrl.pathname), + ); + destination.searchParams.set("mt", "8"); + return destination.toString(); +} + +export function createAppStoreAnalyticsParams({ + currentUrl, + destination, + variant, +}: { + currentUrl: URL; + destination: string; + variant: AppStorePromotionVariant; +}) { + const params: Record = { + app_store_placement: variant, + origin_path: currentUrl.pathname, + link_domain: "apps.apple.com", + link_url: destination, + outbound: true, + }; + + for (const name of CAMPAIGN_PARAMETER_NAMES) { + const value = currentUrl.searchParams.get(name); + if (value) params[name] = value.slice(0, 100); + } + + for (const name of AD_CLICK_ID_NAMES) { + if (currentUrl.searchParams.has(name)) params[`has_${name}`] = true; + } + + return params; +}