Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Posthog to Site #160

Merged
merged 20 commits into from
Aug 9, 2024
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
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
Loading