Skip to content

Commit

Permalink
Add Posthog to Site (#160)
Browse files Browse the repository at this point in the history
* ph module

* capture hubspot forms

* global middleware for feature flags

* fix types / change interval

* small type fix

* button capture for posthog

* add types for pagebuilder

* add experiment types

* Add posthog to page

* add posthog

* fix url for posthog module

* fix typage

* fix url again

* update module yet again

* temp fixes

* fix ui host behavior

* new form logic

* uncomment gtm

* remove logs
  • Loading branch information
bryantgillespie authored Aug 9, 2024
1 parent f88d2c4 commit 6f5c22c
Show file tree
Hide file tree
Showing 26 changed files with 676 additions and 52 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ DIRECTUS_URL="https://your-instance.directus.app"
DIRECTUS_TV_URL="https://your-instance.directus.app"
GOOGLE_TAG_MANAGER_ID="GTM-PTLT3GH"
NUXT_PUBLIC_SITE_URL=https://directus.io
POSTHOG_API_KEY="phc_project_api_key"
POSTHOG_API_HOST="https://us.i.posthog.com"
2 changes: 1 addition & 1 deletion components/Base/DirectusVideo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface DirectusVideoProps {
const props = defineProps<DirectusVideoProps>();
const src = computed(() => {
const url = new URL(`/assets/${props.uuid}`, directusUrl);
const url = new URL(`/assets/${props.uuid}`, directusUrl as string);
return url.toString();
});
</script>
Expand Down
29 changes: 24 additions & 5 deletions components/Base/HsForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const props = withDefaults(defineProps<BaseHsFormProps>(), {
const { formId } = toRefs(props);
const { $directus, $readSingleton } = useNuxtApp();
const { $directus, $readSingleton, $posthog } = useNuxtApp();
declare global {
var hbspt: any;
Expand All @@ -25,12 +25,27 @@ const { data: globals } = useAsyncData('sales-reps', () =>
$directus.request($readSingleton('globals', { fields: ['reps'] })),
);
function formSubmitCallback(form: any, data: any) {
// Track form submission in PH
$posthog?.capture('marketing.site.forms.hubspot.submit', {
form_id: formId.value,
form_data: data,
});
// Redirect to meeting link on form submission
if (props.routeToMeetingLinkOnSuccess) {
routeToMeetingLinkCallback(form, data);
}
}
function routeToMeetingLinkCallback(form: any, data: any) {
const fallbackLink = 'https://directus.io/thanks';
const reason = data.submissionValues.lets_chat_reason ?? null;
const country = data.submissionValues.country_region__picklist_ ?? null;
const state = data.submissionValues.state_region__picklist_ ?? null;
const redirectReasons = ["I'd like a guided demo of Directus", 'I am interested in Directus Enterprise'];
const reps = unref(globals)?.reps ?? [];
const fallbackLink = 'https://directus.io/thanks';
function getSalesRepLink(country: string, state = null) {
for (const rep of reps) {
Expand All @@ -44,8 +59,12 @@ function routeToMeetingLinkCallback(form: any, data: any) {
return fallbackLink;
}
const link = getSalesRepLink(country, state);
window.location.href = link;
if (reason && redirectReasons.includes(reason)) {
const link = getSalesRepLink(country, state);
window.location.href = link;
} else {
window.location.href = fallbackLink;
}
}
const renderHsForm = () => {
Expand All @@ -54,7 +73,7 @@ const renderHsForm = () => {
portalId: '20534155',
formId: unref(formId),
target: `#${unref(generatedId)}`,
onFormSubmitted: props.routeToMeetingLinkOnSuccess ? routeToMeetingLinkCallback : undefined,
onFormSubmitted: formSubmitCallback,
});
};
Expand Down
2 changes: 2 additions & 0 deletions components/Block/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { data: block } = await useAsyncData(props.uuid, () =>
'icon',
'size',
{ page: ['permalink'], resource: ['slug', { type: ['slug'] }] },
'ph_event',
],
}),
),
Expand Down Expand Up @@ -44,6 +45,7 @@ const href = computed(() => {
<template>
<BaseButton
v-if="block"
v-capture="block.ph_event ? { name: block.ph_event, properties: { block } } : ''"
:href="href"
:color="block.color"
:icon="block.icon ?? undefined"
Expand Down
4 changes: 3 additions & 1 deletion components/PageBuilder.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { BlockType, PageBlock } from '~/types/schema';
import type { BlockType, PageBlock, Experiment, ExperimentVariant } from '~/types/schema';
interface PageBuilderProps {
spacingTop?: 'small' | 'normal';
Expand All @@ -20,6 +20,8 @@ export interface PageSectionBlock {
spacing: 'none' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large';
width: 'full' | 'standard' | 'narrow';
key: string | null;
experiment?: Experiment | string | null;
experiment_variant?: ExperimentVariant | string | null;
}
withDefaults(defineProps<PageBuilderProps>(), {
Expand Down
21 changes: 21 additions & 0 deletions middleware/experiments.global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default defineNuxtRouteMiddleware((to) => {
const posthogFeatureFlagsPayload = useState<Record<string, boolean | string> | undefined>('ph-feature-flag-payloads');

if (!posthogFeatureFlagsPayload.value) return;

// Clone the Vue proxy object to a plain object
const flags = Object.values(JSON.parse(JSON.stringify(posthogFeatureFlagsPayload.value)));

let redirectTo;

flags.some((flag: any) => {
if (flag.experiment_type === 'page' && to.path === flag.control_path && flag.control_path !== flag.path) {
redirectTo = flag.path;
return true;
}
});

if (redirectTo) {
return navigateTo(redirectTo);
}
});
108 changes: 108 additions & 0 deletions modules/posthog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { defineNuxtModule, addImports, addComponent, addPlugin, createResolver, addTypeTemplate } from '@nuxt/kit';
import type { PostHogConfig } from 'posthog-js';
import { defu } from 'defu';

export interface ModuleOptions {
/**
* The PostHog API key
* @default process.env.POSTHOG_API_KEY
* @example 'phc_1234567890abcdef1234567890abcdef1234567890a'
* @type string
* @docs https://posthog.com/docs/api
*/
publicKey: string;

/**
* The PostHog API host
* @default process.env.POSTHOG_API_HOST
* @example 'https://app.posthog.com'
* @type string
* @docs https://posthog.com/docs/api
*/
host: string;

/**
* If set to true, the module will capture page views automatically
* @default true
* @type boolean
* @docs https://posthog.com/docs/product-analytics/capture-events#single-page-apps-and-pageviews
*/
capturePageViews?: boolean;

/**
* PostHog Client options
* @default {
* api_host: process.env.POSTHOG_API_HOST,
* loaded: () => <enable debug mode if in development>
* }
* @type object
* @docs https://posthog.com/docs/libraries/js#config
*/
clientOptions?: Partial<PostHogConfig>;

/**
* If set to true, the module will be disabled (no events will be sent to PostHog).
* This is useful for development environments. Directives and components will still be available for you to use.
* @default false
* @type boolean
*/
disabled?: boolean;
}

export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'nuxt-posthog',
configKey: 'posthog',
},
defaults: {
publicKey: process.env.POSTHOG_API_KEY as string,
host: process.env.POSTHOG_API_HOST as string,
capturePageViews: true,
disabled: false,
},
setup(options, nuxt) {
const { resolve } = createResolver(import.meta.url);

// Public runtimeConfig
nuxt.options.runtimeConfig.public.posthog = defu<ModuleOptions, ModuleOptions[]>(
nuxt.options.runtimeConfig.public.posthog,
{
publicKey: options.publicKey,
host: options.host,
capturePageViews: options.capturePageViews,
clientOptions: options.clientOptions,
disabled: options.disabled,
},
);

// Make sure url and key are set
if (!nuxt.options.runtimeConfig.public.posthog.publicKey) {
// eslint-disable-next-line no-console
console.warn('Missing PostHog API public key, set it either in `nuxt.config.ts` or via env variable');
}

if (!nuxt.options.runtimeConfig.public.posthog.host) {
// eslint-disable-next-line no-console
console.warn('Missing PostHog API host, set it either in `nuxt.config.ts` or via env variable');
}

addPlugin(resolve('./runtime/plugins/directives'));
addPlugin(resolve('./runtime/plugins/posthog.client'));
addPlugin(resolve('./runtime/plugins/posthog.server'));

addImports({
from: resolve('./runtime/composables/usePostHogFeatureFlag'),
name: 'usePostHogFeatureFlag',
});

addComponent({
filePath: resolve('./runtime/components/PostHogFeatureFlag.vue'),
name: 'PostHogFeatureFlag',
});

addTypeTemplate({
filename: 'types/posthog-directives.d.ts',
src: resolve('./runtime/types/directives.d.ts'),
});
},
});
20 changes: 20 additions & 0 deletions modules/posthog/runtime/components/PostHogFeatureFlag.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue';
import usePostHogFeatureFlag from '../composables/usePostHogFeatureFlag';
const { name } = withDefaults(
defineProps<{
name: string;
match?: boolean | string;
}>(),
{ match: true },
);
const { getFeatureFlag } = usePostHogFeatureFlag();
const featureFlag = computed(() => getFeatureFlag(name));
</script>

<template>
<slot v-if="featureFlag?.value === match" :payload="featureFlag.payload" />
</template>
23 changes: 23 additions & 0 deletions modules/posthog/runtime/composables/usePostHogFeatureFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState } from '#app';
import type { JsonType } from 'posthog-js';

export default () => {
const posthogFeatureFlags = useState<Record<string, boolean | string> | undefined>('ph-feature-flags');
const posthogFeatureFlagPayloads = useState<Record<string, JsonType> | undefined>('ph-feature-flag-payloads');

const isFeatureEnabled = (feature: string) => {
return posthogFeatureFlags.value?.[feature] ?? false;
};

const getFeatureFlag = (feature: string) => {
return {
value: posthogFeatureFlags.value?.[feature] ?? false,
payload: posthogFeatureFlagPayloads.value?.[feature],
};
};

return {
isFeatureEnabled,
getFeatureFlag,
};
};
88 changes: 88 additions & 0 deletions modules/posthog/runtime/directives/v-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useNuxtApp } from '#app';
import type { ObjectDirective, FunctionDirective, DirectiveBinding } from 'vue';

type CaptureEvent = {
name: string;
properties?: Record<string, any>;
};

type CaptureModifiers = {
click?: boolean;
hover?: boolean;
};

type EventHandler = {
event: string;
handler: (event: Event) => void;
};

const listeners = new WeakMap<HTMLElement, EventHandler[]>();

const directive: FunctionDirective<HTMLElement, CaptureEvent | string> = (
el,
binding: DirectiveBinding<CaptureEvent | string> & { modifiers: CaptureModifiers },
) => {
const { value, modifiers } = binding;

// Don't bind if the value is undefined
if (!value) {
return;
}

const { $posthog } = useNuxtApp();

function capture(_event: Event) {
if (!$posthog) return;

if (typeof value === 'string') {
$posthog.capture(value);
} else {
$posthog.capture(value.name, value.properties);
}
}

// Determine the events to listen for based on the modifiers
const events: string[] = [];

if (Object.keys(modifiers).length === 0) {
// Default to click if no modifiers are specified
events.push('click');
} else {
if (modifiers.click) events.push('click');
if (modifiers.hover) events.push('mouseenter');
}

// Remove existing event listeners
if (listeners.has(el)) {
const oldEvents = listeners.get(el) as EventHandler[];

oldEvents.forEach(({ event, handler }) => {
el.removeEventListener(event, handler);
});
}

// Add new event listeners and store them
const eventHandlers = events.map((event) => {
const handler = capture.bind(null);
el.addEventListener(event, handler);
return { event, handler };
});

listeners.set(el, eventHandlers);
};

export const vCapture: ObjectDirective = {
mounted: directive,
updated: directive,
unmounted(el) {
if (listeners.has(el)) {
const eventHandlers = listeners.get(el) as EventHandler[];

eventHandlers.forEach(({ event, handler }) => {
el.removeEventListener(event, handler);
});

listeners.delete(el);
}
},
};
6 changes: 6 additions & 0 deletions modules/posthog/runtime/plugins/directives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { vCapture } from '../directives/v-capture';
import { defineNuxtPlugin } from '#app';

export default defineNuxtPlugin(({ vueApp }) => {
vueApp.directive('capture', vCapture);
});
Loading

0 comments on commit 6f5c22c

Please sign in to comment.