Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/PRODUCT-EXPERIMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions scripts/tests/app-store-attribution.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -161,6 +162,10 @@ export function InsomniaPage({
</section>

<div className="relative z-10 mx-auto w-full max-w-6xl rounded-t-[48px] bg-background/95 px-4 pb-16 pt-16 backdrop-blur-sm sm:px-6 lg:px-8">
{!renderContext ? (
<AppStorePromotion className="mb-12" variant="strip" />
) : null}

<p className="mb-6 text-xs text-muted-foreground">{copy("updated")}</p>

<section className="grid gap-6 md:grid-cols-2">
Expand Down
5 changes: 5 additions & 0 deletions src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -190,6 +191,10 @@ export function TimerPage({
</section>

<div className="relative z-10 mx-auto w-full max-w-6xl rounded-t-[48px] bg-background/95 px-4 pb-16 pt-16 backdrop-blur-sm sm:px-6 lg:px-8">
{!renderContext ? (
<AppStorePromotion className="mb-12" variant="strip" />
) : null}

<p className="mb-6 text-xs text-muted-foreground">{copy("updated")}</p>
<div className="mb-8 glow-card rounded-[32px] border border-border bg-card p-6">
<h2 className="text-2xl font-semibold text-card-foreground">{copy("quickAnswer.title")}</h2>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -172,6 +173,10 @@ export function BoxBreathingAppPage({
</section>

<div className="relative z-10 mx-auto w-full max-w-6xl rounded-t-[48px] bg-background/95 px-4 pb-16 pt-16 backdrop-blur-sm sm:px-6 lg:px-8">
{!renderContext ? (
<AppStorePromotion className="mb-12" variant="strip" />
) : null}

<div className="mb-8 glow-card rounded-[32px] border border-border bg-card p-6">
<h2 className="text-2xl font-semibold text-card-foreground">
{copy("quickAnswer.title")}
Expand Down
3 changes: 3 additions & 0 deletions src/app/(site-en)/breathe/pattern-page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -258,6 +259,8 @@ export function PatternPage({
</section>

<section className="relative z-10 mx-auto mt-6 w-full max-w-6xl space-y-12 rounded-t-[48px] bg-background/95 px-4 pb-20 pt-16 backdrop-blur-sm sm:px-6 lg:px-8">
{!renderContext ? <AppStorePromotion variant="strip" /> : null}

{page.meta.dateModified && (
<p className="text-xs text-muted-foreground -mt-6" data-i18n="credentials">
<LocalizedDate
Expand Down
3 changes: 3 additions & 0 deletions src/app/(site-en)/breathing-app/breathing-app-page.tsx
Original file line number Diff line number Diff line change
@@ -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/breathing-app.json";
import type {
Expand Down Expand Up @@ -103,6 +104,8 @@ export function BreathingAppPage({
</p>
</header>

{!renderContext ? <AppStorePromotion variant="landing" /> : null}

<section className="mt-10 grid gap-6 md:grid-cols-2">
<div className="glow-card rounded-[32px] border border-border bg-card p-6">
<h2 className="text-2xl font-semibold text-card-foreground">
Expand Down
5 changes: 5 additions & 0 deletions src/app/(site-en)/breathing-visualizer/visualizer-page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -215,6 +216,10 @@ export function BreathingVisualizerPage({
</section>

<div className="relative z-10 mx-auto w-full max-w-6xl rounded-t-[48px] bg-background/95 px-4 pb-16 pt-16 backdrop-blur-sm sm:px-6 lg:px-8">
{!renderContext ? (
<AppStorePromotion className="mb-12" variant="strip" />
) : null}

{/* Voice search / Quick answer */}
<div className="mb-12 glow-card rounded-[32px] border border-border bg-card p-6">
<h2 className="text-2xl font-semibold text-card-foreground">
Expand Down
3 changes: 3 additions & 0 deletions src/app/(site-en)/for/use-case-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -375,6 +376,8 @@ export function UseCasePage({
: undefined
}
>
{!renderContext ? <AppStorePromotion variant="strip" /> : null}

{/* Back to Holiday Hub link */}
{isHolidayPage && (
<div className="-mt-4 mb-2">
Expand Down
3 changes: 3 additions & 0 deletions src/app/(site-en)/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -276,6 +277,8 @@ export function HomePage({
</section>

<div className="relative z-10 mx-auto flex w-full max-w-6xl flex-col gap-16 px-4 py-16 sm:px-6 lg:px-8">
{!renderContext ? <AppStorePromotion variant="strip" /> : null}

<section className="text-center">
<h2 className="text-3xl font-semibold text-card-foreground">
{content.sections.precision.title}
Expand Down
5 changes: 5 additions & 0 deletions src/app/(site-en)/resonance-guide-page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -474,6 +475,10 @@ export function ResonanceGuidePage({
</section>

<div className="relative z-10 mx-auto w-full max-w-6xl rounded-t-[48px] bg-background/95 px-4 pb-16 pt-16 backdrop-blur-sm sm:px-6 lg:px-8">
{!renderContext ? (
<AppStorePromotion className="mb-12" variant="strip" />
) : null}

<p className="mb-6 text-xs text-muted-foreground">{content.updated}</p>

{content.alert ? (
Expand Down
17 changes: 17 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading