Skip to content

Commit 6f5c22c

Browse files
Add Posthog to Site (#160)
* 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
1 parent f88d2c4 commit 6f5c22c

26 files changed

+676
-52
lines changed

.env.example

+2
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ DIRECTUS_URL="https://your-instance.directus.app"
22
DIRECTUS_TV_URL="https://your-instance.directus.app"
33
GOOGLE_TAG_MANAGER_ID="GTM-PTLT3GH"
44
NUXT_PUBLIC_SITE_URL=https://directus.io
5+
POSTHOG_API_KEY="phc_project_api_key"
6+
POSTHOG_API_HOST="https://us.i.posthog.com"

components/Base/DirectusVideo.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface DirectusVideoProps {
1919
const props = defineProps<DirectusVideoProps>();
2020
2121
const src = computed(() => {
22-
const url = new URL(`/assets/${props.uuid}`, directusUrl);
22+
const url = new URL(`/assets/${props.uuid}`, directusUrl as string);
2323
return url.toString();
2424
});
2525
</script>

components/Base/HsForm.vue

+24-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<BaseHsFormProps>(), {
1515
1616
const { formId } = toRefs(props);
1717
18-
const { $directus, $readSingleton } = useNuxtApp();
18+
const { $directus, $readSingleton, $posthog } = useNuxtApp();
1919
2020
declare global {
2121
var hbspt: any;
@@ -25,12 +25,27 @@ const { data: globals } = useAsyncData('sales-reps', () =>
2525
$directus.request($readSingleton('globals', { fields: ['reps'] })),
2626
);
2727
28+
function formSubmitCallback(form: any, data: any) {
29+
// Track form submission in PH
30+
$posthog?.capture('marketing.site.forms.hubspot.submit', {
31+
form_id: formId.value,
32+
form_data: data,
33+
});
34+
35+
// Redirect to meeting link on form submission
36+
if (props.routeToMeetingLinkOnSuccess) {
37+
routeToMeetingLinkCallback(form, data);
38+
}
39+
}
40+
2841
function routeToMeetingLinkCallback(form: any, data: any) {
42+
const fallbackLink = 'https://directus.io/thanks';
43+
const reason = data.submissionValues.lets_chat_reason ?? null;
2944
const country = data.submissionValues.country_region__picklist_ ?? null;
3045
const state = data.submissionValues.state_region__picklist_ ?? null;
3146
47+
const redirectReasons = ["I'd like a guided demo of Directus", 'I am interested in Directus Enterprise'];
3248
const reps = unref(globals)?.reps ?? [];
33-
const fallbackLink = 'https://directus.io/thanks';
3449
3550
function getSalesRepLink(country: string, state = null) {
3651
for (const rep of reps) {
@@ -44,8 +59,12 @@ function routeToMeetingLinkCallback(form: any, data: any) {
4459
return fallbackLink;
4560
}
4661
47-
const link = getSalesRepLink(country, state);
48-
window.location.href = link;
62+
if (reason && redirectReasons.includes(reason)) {
63+
const link = getSalesRepLink(country, state);
64+
window.location.href = link;
65+
} else {
66+
window.location.href = fallbackLink;
67+
}
4968
}
5069
5170
const renderHsForm = () => {
@@ -54,7 +73,7 @@ const renderHsForm = () => {
5473
portalId: '20534155',
5574
formId: unref(formId),
5675
target: `#${unref(generatedId)}`,
57-
onFormSubmitted: props.routeToMeetingLinkOnSuccess ? routeToMeetingLinkCallback : undefined,
76+
onFormSubmitted: formSubmitCallback,
5877
});
5978
};
6079

components/Block/Button.vue

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const { data: block } = await useAsyncData(props.uuid, () =>
1717
'icon',
1818
'size',
1919
{ page: ['permalink'], resource: ['slug', { type: ['slug'] }] },
20+
'ph_event',
2021
],
2122
}),
2223
),
@@ -44,6 +45,7 @@ const href = computed(() => {
4445
<template>
4546
<BaseButton
4647
v-if="block"
48+
v-capture="block.ph_event ? { name: block.ph_event, properties: { block } } : ''"
4749
:href="href"
4850
:color="block.color"
4951
:icon="block.icon ?? undefined"

components/PageBuilder.vue

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import type { BlockType, PageBlock } from '~/types/schema';
2+
import type { BlockType, PageBlock, Experiment, ExperimentVariant } from '~/types/schema';
33
44
interface PageBuilderProps {
55
spacingTop?: 'small' | 'normal';
@@ -20,6 +20,8 @@ export interface PageSectionBlock {
2020
spacing: 'none' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large';
2121
width: 'full' | 'standard' | 'narrow';
2222
key: string | null;
23+
experiment?: Experiment | string | null;
24+
experiment_variant?: ExperimentVariant | string | null;
2325
}
2426
2527
withDefaults(defineProps<PageBuilderProps>(), {

middleware/experiments.global.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export default defineNuxtRouteMiddleware((to) => {
2+
const posthogFeatureFlagsPayload = useState<Record<string, boolean | string> | undefined>('ph-feature-flag-payloads');
3+
4+
if (!posthogFeatureFlagsPayload.value) return;
5+
6+
// Clone the Vue proxy object to a plain object
7+
const flags = Object.values(JSON.parse(JSON.stringify(posthogFeatureFlagsPayload.value)));
8+
9+
let redirectTo;
10+
11+
flags.some((flag: any) => {
12+
if (flag.experiment_type === 'page' && to.path === flag.control_path && flag.control_path !== flag.path) {
13+
redirectTo = flag.path;
14+
return true;
15+
}
16+
});
17+
18+
if (redirectTo) {
19+
return navigateTo(redirectTo);
20+
}
21+
});

modules/posthog/index.ts

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { defineNuxtModule, addImports, addComponent, addPlugin, createResolver, addTypeTemplate } from '@nuxt/kit';
2+
import type { PostHogConfig } from 'posthog-js';
3+
import { defu } from 'defu';
4+
5+
export interface ModuleOptions {
6+
/**
7+
* The PostHog API key
8+
* @default process.env.POSTHOG_API_KEY
9+
* @example 'phc_1234567890abcdef1234567890abcdef1234567890a'
10+
* @type string
11+
* @docs https://posthog.com/docs/api
12+
*/
13+
publicKey: string;
14+
15+
/**
16+
* The PostHog API host
17+
* @default process.env.POSTHOG_API_HOST
18+
* @example 'https://app.posthog.com'
19+
* @type string
20+
* @docs https://posthog.com/docs/api
21+
*/
22+
host: string;
23+
24+
/**
25+
* If set to true, the module will capture page views automatically
26+
* @default true
27+
* @type boolean
28+
* @docs https://posthog.com/docs/product-analytics/capture-events#single-page-apps-and-pageviews
29+
*/
30+
capturePageViews?: boolean;
31+
32+
/**
33+
* PostHog Client options
34+
* @default {
35+
* api_host: process.env.POSTHOG_API_HOST,
36+
* loaded: () => <enable debug mode if in development>
37+
* }
38+
* @type object
39+
* @docs https://posthog.com/docs/libraries/js#config
40+
*/
41+
clientOptions?: Partial<PostHogConfig>;
42+
43+
/**
44+
* If set to true, the module will be disabled (no events will be sent to PostHog).
45+
* This is useful for development environments. Directives and components will still be available for you to use.
46+
* @default false
47+
* @type boolean
48+
*/
49+
disabled?: boolean;
50+
}
51+
52+
export default defineNuxtModule<ModuleOptions>({
53+
meta: {
54+
name: 'nuxt-posthog',
55+
configKey: 'posthog',
56+
},
57+
defaults: {
58+
publicKey: process.env.POSTHOG_API_KEY as string,
59+
host: process.env.POSTHOG_API_HOST as string,
60+
capturePageViews: true,
61+
disabled: false,
62+
},
63+
setup(options, nuxt) {
64+
const { resolve } = createResolver(import.meta.url);
65+
66+
// Public runtimeConfig
67+
nuxt.options.runtimeConfig.public.posthog = defu<ModuleOptions, ModuleOptions[]>(
68+
nuxt.options.runtimeConfig.public.posthog,
69+
{
70+
publicKey: options.publicKey,
71+
host: options.host,
72+
capturePageViews: options.capturePageViews,
73+
clientOptions: options.clientOptions,
74+
disabled: options.disabled,
75+
},
76+
);
77+
78+
// Make sure url and key are set
79+
if (!nuxt.options.runtimeConfig.public.posthog.publicKey) {
80+
// eslint-disable-next-line no-console
81+
console.warn('Missing PostHog API public key, set it either in `nuxt.config.ts` or via env variable');
82+
}
83+
84+
if (!nuxt.options.runtimeConfig.public.posthog.host) {
85+
// eslint-disable-next-line no-console
86+
console.warn('Missing PostHog API host, set it either in `nuxt.config.ts` or via env variable');
87+
}
88+
89+
addPlugin(resolve('./runtime/plugins/directives'));
90+
addPlugin(resolve('./runtime/plugins/posthog.client'));
91+
addPlugin(resolve('./runtime/plugins/posthog.server'));
92+
93+
addImports({
94+
from: resolve('./runtime/composables/usePostHogFeatureFlag'),
95+
name: 'usePostHogFeatureFlag',
96+
});
97+
98+
addComponent({
99+
filePath: resolve('./runtime/components/PostHogFeatureFlag.vue'),
100+
name: 'PostHogFeatureFlag',
101+
});
102+
103+
addTypeTemplate({
104+
filename: 'types/posthog-directives.d.ts',
105+
src: resolve('./runtime/types/directives.d.ts'),
106+
});
107+
},
108+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import usePostHogFeatureFlag from '../composables/usePostHogFeatureFlag';
4+
5+
const { name } = withDefaults(
6+
defineProps<{
7+
name: string;
8+
match?: boolean | string;
9+
}>(),
10+
{ match: true },
11+
);
12+
13+
const { getFeatureFlag } = usePostHogFeatureFlag();
14+
15+
const featureFlag = computed(() => getFeatureFlag(name));
16+
</script>
17+
18+
<template>
19+
<slot v-if="featureFlag?.value === match" :payload="featureFlag.payload" />
20+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useState } from '#app';
2+
import type { JsonType } from 'posthog-js';
3+
4+
export default () => {
5+
const posthogFeatureFlags = useState<Record<string, boolean | string> | undefined>('ph-feature-flags');
6+
const posthogFeatureFlagPayloads = useState<Record<string, JsonType> | undefined>('ph-feature-flag-payloads');
7+
8+
const isFeatureEnabled = (feature: string) => {
9+
return posthogFeatureFlags.value?.[feature] ?? false;
10+
};
11+
12+
const getFeatureFlag = (feature: string) => {
13+
return {
14+
value: posthogFeatureFlags.value?.[feature] ?? false,
15+
payload: posthogFeatureFlagPayloads.value?.[feature],
16+
};
17+
};
18+
19+
return {
20+
isFeatureEnabled,
21+
getFeatureFlag,
22+
};
23+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useNuxtApp } from '#app';
2+
import type { ObjectDirective, FunctionDirective, DirectiveBinding } from 'vue';
3+
4+
type CaptureEvent = {
5+
name: string;
6+
properties?: Record<string, any>;
7+
};
8+
9+
type CaptureModifiers = {
10+
click?: boolean;
11+
hover?: boolean;
12+
};
13+
14+
type EventHandler = {
15+
event: string;
16+
handler: (event: Event) => void;
17+
};
18+
19+
const listeners = new WeakMap<HTMLElement, EventHandler[]>();
20+
21+
const directive: FunctionDirective<HTMLElement, CaptureEvent | string> = (
22+
el,
23+
binding: DirectiveBinding<CaptureEvent | string> & { modifiers: CaptureModifiers },
24+
) => {
25+
const { value, modifiers } = binding;
26+
27+
// Don't bind if the value is undefined
28+
if (!value) {
29+
return;
30+
}
31+
32+
const { $posthog } = useNuxtApp();
33+
34+
function capture(_event: Event) {
35+
if (!$posthog) return;
36+
37+
if (typeof value === 'string') {
38+
$posthog.capture(value);
39+
} else {
40+
$posthog.capture(value.name, value.properties);
41+
}
42+
}
43+
44+
// Determine the events to listen for based on the modifiers
45+
const events: string[] = [];
46+
47+
if (Object.keys(modifiers).length === 0) {
48+
// Default to click if no modifiers are specified
49+
events.push('click');
50+
} else {
51+
if (modifiers.click) events.push('click');
52+
if (modifiers.hover) events.push('mouseenter');
53+
}
54+
55+
// Remove existing event listeners
56+
if (listeners.has(el)) {
57+
const oldEvents = listeners.get(el) as EventHandler[];
58+
59+
oldEvents.forEach(({ event, handler }) => {
60+
el.removeEventListener(event, handler);
61+
});
62+
}
63+
64+
// Add new event listeners and store them
65+
const eventHandlers = events.map((event) => {
66+
const handler = capture.bind(null);
67+
el.addEventListener(event, handler);
68+
return { event, handler };
69+
});
70+
71+
listeners.set(el, eventHandlers);
72+
};
73+
74+
export const vCapture: ObjectDirective = {
75+
mounted: directive,
76+
updated: directive,
77+
unmounted(el) {
78+
if (listeners.has(el)) {
79+
const eventHandlers = listeners.get(el) as EventHandler[];
80+
81+
eventHandlers.forEach(({ event, handler }) => {
82+
el.removeEventListener(event, handler);
83+
});
84+
85+
listeners.delete(el);
86+
}
87+
},
88+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { vCapture } from '../directives/v-capture';
2+
import { defineNuxtPlugin } from '#app';
3+
4+
export default defineNuxtPlugin(({ vueApp }) => {
5+
vueApp.directive('capture', vCapture);
6+
});

0 commit comments

Comments
 (0)