mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(console): refactor conversion tracking (#5466)
* refactor(console): refactor conversion tracking * refactor(console): manually set enhanced user data * refactor: report production tenant conversion * refactor(console): use transaction id for reporting
This commit is contained in:
parent
3ae59baf6e
commit
b47c2a2de1
12 changed files with 316 additions and 187 deletions
|
@ -27,6 +27,7 @@ import { OnboardingRoutes } from '@/onboarding';
|
||||||
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data';
|
||||||
import { ConsoleRoutes } from '@/pages/ConsoleRoutes';
|
import { ConsoleRoutes } from '@/pages/ConsoleRoutes';
|
||||||
|
|
||||||
|
import { GlobalScripts } from './components/Conversion';
|
||||||
import { adminTenantEndpoint, mainTitle } from './consts';
|
import { adminTenantEndpoint, mainTitle } from './consts';
|
||||||
import ErrorBoundary from './containers/ErrorBoundary';
|
import ErrorBoundary from './containers/ErrorBoundary';
|
||||||
import LogtoErrorBoundary from './containers/LogtoErrorBoundary';
|
import LogtoErrorBoundary from './containers/LogtoErrorBoundary';
|
||||||
|
@ -153,5 +154,10 @@ function AppRoutes() {
|
||||||
return <AppLoading />;
|
return <AppLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isAuthenticated && isOnboarding ? <OnboardingRoutes /> : <ConsoleRoutes />;
|
return (
|
||||||
|
<>
|
||||||
|
<GlobalScripts />
|
||||||
|
{isAuthenticated && isOnboarding ? <OnboardingRoutes /> : <ConsoleRoutes />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
117
packages/console/src/components/Conversion/index.tsx
Normal file
117
packages/console/src/components/Conversion/index.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import useCurrentUser from '@/hooks/use-current-user';
|
||||||
|
|
||||||
|
import { useRetry } from './use-retry';
|
||||||
|
import {
|
||||||
|
shouldReport,
|
||||||
|
gtagAwTrackingId,
|
||||||
|
redditPixelId,
|
||||||
|
hashEmail,
|
||||||
|
type GtagConversionId,
|
||||||
|
type RedditReportType,
|
||||||
|
reportToGoogle,
|
||||||
|
reportToReddit,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
type ScriptProps = {
|
||||||
|
userEmailHash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GoogleScripts({ userEmailHash }: ScriptProps) {
|
||||||
|
if (!userEmailHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
src={`https://www.googletagmanager.com/gtag/js?id=${gtagAwTrackingId}`}
|
||||||
|
/>
|
||||||
|
<script>{`
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '${gtagAwTrackingId}', { 'allow_enhanced_conversions': true });
|
||||||
|
gtag('set', 'user_data', { 'sha256_email_address': '${userEmailHash}' });
|
||||||
|
`}</script>
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedditScripts({ userEmailHash }: ScriptProps) {
|
||||||
|
if (!userEmailHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
<script>{`
|
||||||
|
!function(w,d){if(!w.rdt){var p=w.rdt=function(){p.sendEvent?p.sendEvent.apply(p,arguments):p.callQueue.push(arguments)};p.callQueue=[];var t=d.createElement("script");t.src="https://www.redditstatic.com/ads/pixel.js",t.async=!0;var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(t,s)}}(window,document);
|
||||||
|
rdt('init', '${redditPixelId}', {
|
||||||
|
optOut: false,
|
||||||
|
useDecimalCurrencyValues: true,
|
||||||
|
email: '${userEmailHash}'
|
||||||
|
});
|
||||||
|
`}</script>
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders global scripts for conversion tracking.
|
||||||
|
*/
|
||||||
|
export function GlobalScripts() {
|
||||||
|
const { user, isLoaded } = useCurrentUser();
|
||||||
|
const [userEmailHash, setUserEmailHash] = useState<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use user email to prevent duplicate conversion, and it is hashed before sending
|
||||||
|
* to protect user privacy.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
setUserEmailHash(await hashEmail(user?.primaryEmail ?? undefined));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoaded) {
|
||||||
|
void init();
|
||||||
|
}
|
||||||
|
}, [user, isLoaded]);
|
||||||
|
|
||||||
|
if (!shouldReport) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GoogleScripts userEmailHash={userEmailHash} />
|
||||||
|
<RedditScripts userEmailHash={userEmailHash} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportConversionOptions = {
|
||||||
|
transactionId?: string;
|
||||||
|
gtagId?: GtagConversionId;
|
||||||
|
redditType?: RedditReportType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useReportConversion = ({
|
||||||
|
gtagId,
|
||||||
|
redditType,
|
||||||
|
transactionId,
|
||||||
|
}: ReportConversionOptions) => {
|
||||||
|
useRetry({
|
||||||
|
precondition: Boolean(shouldReport && gtagId),
|
||||||
|
execute: () => (gtagId ? reportToGoogle(gtagId, { transactionId }) : false),
|
||||||
|
});
|
||||||
|
|
||||||
|
useRetry({
|
||||||
|
precondition: Boolean(shouldReport && redditType),
|
||||||
|
execute: () => (redditType ? reportToReddit(redditType) : false),
|
||||||
|
});
|
||||||
|
};
|
52
packages/console/src/components/Conversion/use-retry.ts
Normal file
52
packages/console/src/components/Conversion/use-retry.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
type UseRetryOptions = {
|
||||||
|
/** The precondition to check before executing the function. */
|
||||||
|
precondition: boolean;
|
||||||
|
/** The function to execute when the precondition is not met. */
|
||||||
|
onPreconditionFailed?: () => void;
|
||||||
|
/** The function to execute. If it returns `true`, the retry will stop. */
|
||||||
|
execute: () => boolean;
|
||||||
|
/**
|
||||||
|
* The maximum number of retries.
|
||||||
|
*
|
||||||
|
* @default 3
|
||||||
|
*/
|
||||||
|
maxRetry?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook to retry a function until the condition is met. The retry interval is 1 second.
|
||||||
|
*/
|
||||||
|
export const useRetry = ({
|
||||||
|
precondition,
|
||||||
|
onPreconditionFailed,
|
||||||
|
execute,
|
||||||
|
maxRetry = 3,
|
||||||
|
}: UseRetryOptions) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!precondition) {
|
||||||
|
onPreconditionFailed?.();
|
||||||
|
}
|
||||||
|
}, [onPreconditionFailed, precondition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!precondition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-let
|
||||||
|
let retry = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (execute() || retry >= maxRetry) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
retry += 1;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [execute, maxRetry, precondition]);
|
||||||
|
};
|
106
packages/console/src/components/Conversion/utils.ts
Normal file
106
packages/console/src/components/Conversion/utils.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { cond } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
import { isProduction } from '@/consts/env';
|
||||||
|
|
||||||
|
export const gtagAwTrackingId = 'AW-11124811245';
|
||||||
|
export enum GtagConversionId {
|
||||||
|
/** This ID indicates a user has truly signed up for Logto Cloud. */
|
||||||
|
SignUp = 'AW-11192640559/ZuqUCLvNpasYEK_IiNkp',
|
||||||
|
/** This ID indicates a user has created a production tenant. */
|
||||||
|
CreateProductionTenant = 'AW-11192640559/m04fCMDrxI0ZEK_IiNkp',
|
||||||
|
/** This ID indicates a user has purchased a Pro plan. */
|
||||||
|
PurchaseProPlan = 'AW-11192640559/WjCtCKHCtpgZEK_IiNkp',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redditPixelId = 't2_ggt11omdo';
|
||||||
|
|
||||||
|
const logtoProductionHostname = 'logto.io';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Due to the special of conversion reporting, it should be `true` only in the
|
||||||
|
* Logto Cloud production environment.
|
||||||
|
* Add the leading '.' to make it safer (ignore hostnames like "foologto.io").
|
||||||
|
*/
|
||||||
|
export const shouldReport = window.location.hostname.endsWith('.' + logtoProductionHostname);
|
||||||
|
|
||||||
|
const sha256 = async (message: string) => {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(message));
|
||||||
|
// https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex
|
||||||
|
return [...new Uint8Array(hash)].map((value) => value.toString(16).padStart(2, '0')).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function will do the following things:
|
||||||
|
*
|
||||||
|
* 1. Canonicalize the given email by Reddit's rule: lowercase, trim,
|
||||||
|
* remove dots, remove everything after the first '+'.
|
||||||
|
* 2. Hash the canonicalized email by SHA256.
|
||||||
|
*/
|
||||||
|
export const hashEmail = async (email?: string) => {
|
||||||
|
if (!email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitEmail = email.toLocaleLowerCase().trim().split('@');
|
||||||
|
const [localPart, domain] = splitEmail;
|
||||||
|
|
||||||
|
if (!localPart || !domain || splitEmail.length > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-string-replace-all
|
||||||
|
const canonicalizedEmail = `${localPart.replace(/\./g, '').replace(/\+.*/, '')}@${domain}`;
|
||||||
|
return sha256(canonicalizedEmail);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Print debug message if not in production. */
|
||||||
|
const debug = (...args: Parameters<(typeof console)['debug']>) => {
|
||||||
|
if (!isProduction) {
|
||||||
|
console.debug(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add more if needed: https://reddit.my.site.com/helpcenter/s/article/Install-the-Reddit-Pixel-on-your-website
|
||||||
|
*/
|
||||||
|
export type RedditReportType =
|
||||||
|
| 'PageVisit'
|
||||||
|
| 'ViewContent'
|
||||||
|
| 'Search'
|
||||||
|
| 'Purchase'
|
||||||
|
| 'Lead'
|
||||||
|
| 'SignUp';
|
||||||
|
|
||||||
|
export const reportToReddit = (redditType: RedditReportType) => {
|
||||||
|
if (!window.rdt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('report:', 'redditType =', redditType);
|
||||||
|
window.rdt('track', redditType);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportToGoogle = (
|
||||||
|
gtagId: GtagConversionId,
|
||||||
|
{ transactionId }: { transactionId?: string } = {}
|
||||||
|
) => {
|
||||||
|
if (!window.gtag) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const transaction = cond(transactionId && { transaction_id: await sha256(transactionId) });
|
||||||
|
|
||||||
|
debug('report:', 'gtagId =', gtagId, 'transaction =', transaction);
|
||||||
|
window.gtag?.('event', 'conversion', {
|
||||||
|
send_to: gtagId,
|
||||||
|
...transaction,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
|
@ -5,6 +5,7 @@ import Modal from 'react-modal';
|
||||||
|
|
||||||
import { useCloudApi, toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
import { useCloudApi, toastResponseError } from '@/cloud/hooks/use-cloud-api';
|
||||||
import { type TenantResponse } from '@/cloud/types/router';
|
import { type TenantResponse } from '@/cloud/types/router';
|
||||||
|
import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils';
|
||||||
import { pricingLink } from '@/consts';
|
import { pricingLink } from '@/consts';
|
||||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||||
import ModalLayout from '@/ds-components/ModalLayout';
|
import ModalLayout from '@/ds-components/ModalLayout';
|
||||||
|
@ -43,6 +44,7 @@ function SelectTenantPlanModal({ tenantData, onClose }: Props) {
|
||||||
const { name, tag } = tenantData;
|
const { name, tag } = tenantData;
|
||||||
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
|
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
|
||||||
|
|
||||||
|
reportToGoogle(GtagConversionId.CreateProductionTenant, { transactionId: newTenant.id });
|
||||||
onClose(newTenant);
|
onClose(newTenant);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,85 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
|
|
||||||
import useCurrentUser from '@/hooks/use-current-user';
|
|
||||||
|
|
||||||
import {
|
|
||||||
shouldReport,
|
|
||||||
lintrk,
|
|
||||||
gtagAwTrackingId,
|
|
||||||
linkedInConversionId,
|
|
||||||
gtag,
|
|
||||||
gtagSignUpConversionId,
|
|
||||||
rdt,
|
|
||||||
redditPixelId,
|
|
||||||
hashEmail,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In cloud production environment, this component initiates gtag.js and LinkedIn
|
|
||||||
* Insight Tag, then reports a sign-up conversion to them.
|
|
||||||
*/
|
|
||||||
export default function ReportConversion() {
|
|
||||||
const { user, isLoading } = useCurrentUser();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate Reddit Pixel and report a sign-up conversion to it when user is loaded.
|
|
||||||
* Use user email to prevent duplicate conversion, and it is hashed before sending
|
|
||||||
* to protect user privacy.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const report = async () => {
|
|
||||||
rdt('init', redditPixelId, {
|
|
||||||
optOut: false,
|
|
||||||
useDecimalCurrencyValues: true,
|
|
||||||
email: await hashEmail(user?.primaryEmail ?? undefined),
|
|
||||||
});
|
|
||||||
rdt('track', 'SignUp');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shouldReport && !isLoading) {
|
|
||||||
void report();
|
|
||||||
}
|
|
||||||
}, [user, isLoading]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This `useEffect()` initiates Google Tag and report a sign-up conversion to it.
|
|
||||||
* It may run multiple times (e.g. a user visit multiple times to finish the onboarding process,
|
|
||||||
* which rarely happens), but it'll be okay since we've set conversion's "Count" to "One"
|
|
||||||
* which means only the first interaction is valuable.
|
|
||||||
*
|
|
||||||
* Track this conversion in the backend has been considered, but Google does not provide
|
|
||||||
* a clear guideline for it and marks the [Node library](https://developers.google.com/tag-platform/tag-manager/api/v2/libraries)
|
|
||||||
* as "alpha" which looks unreliable.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldReport) {
|
|
||||||
gtag('js', new Date());
|
|
||||||
gtag('config', gtagAwTrackingId);
|
|
||||||
gtag('event', 'conversion', {
|
|
||||||
send_to: gtagSignUpConversionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
lintrk('track', { conversion_id: linkedInConversionId });
|
|
||||||
console.debug('Have a good day!');
|
|
||||||
} else {
|
|
||||||
console.debug("Not reporting conversion because it's not production");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (shouldReport) {
|
|
||||||
return (
|
|
||||||
<Helmet>
|
|
||||||
<script
|
|
||||||
async
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
src={`https://www.googletagmanager.com/gtag/js?id=${gtagAwTrackingId}`}
|
|
||||||
/>
|
|
||||||
<script async src="https://snap.licdn.com/li.lms-analytics/insight.min.js" />
|
|
||||||
<script async src="https://www.redditstatic.com/ads/pixel.js" />
|
|
||||||
</Helmet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
|
@ -1,88 +0,0 @@
|
||||||
import { trySafe } from '@silverhand/essentials';
|
|
||||||
|
|
||||||
export const gtagAwTrackingId = 'AW-11124811245';
|
|
||||||
/** This ID indicates a user has truly signed up for Logto Cloud. */
|
|
||||||
export const gtagSignUpConversionId = `AW-11192640559/ZuqUCLvNpasYEK_IiNkp`;
|
|
||||||
const logtoProductionHostname = 'logto.io';
|
|
||||||
const linkedInPartnerId = '5096172';
|
|
||||||
export const linkedInConversionId = '13374828';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Due to the special of conversion reporting, it should be `true` only in the
|
|
||||||
* Logto Cloud production environment.
|
|
||||||
* Add the leading '.' to make it safer (ignore hostnames like "foologto.io").
|
|
||||||
*/
|
|
||||||
export const shouldReport = window.location.hostname.endsWith('.' + logtoProductionHostname);
|
|
||||||
|
|
||||||
/* eslint-disable @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods, prefer-rest-params */
|
|
||||||
|
|
||||||
/** This function is edited from the Google Tag official code snippet. */
|
|
||||||
export function gtag(..._: unknown[]) {
|
|
||||||
trySafe(() => {
|
|
||||||
// We cannot use rest params here since gtag has some internal logic about `arguments` for data transpiling
|
|
||||||
if (!window.dataLayer) {
|
|
||||||
window.dataLayer = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dataLayer.push(arguments);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This function is edited from the LinkedIn Tag official code snippet. */
|
|
||||||
export function lintrk(..._: unknown[]) {
|
|
||||||
// Init LinkedIn tag if needed
|
|
||||||
if (!window._linkedin_data_partner_ids) {
|
|
||||||
window._linkedin_data_partner_ids = [];
|
|
||||||
window._linkedin_data_partner_ids.push(linkedInPartnerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
trySafe(() => {
|
|
||||||
if (!window.lintrk) {
|
|
||||||
window.lintrk = { q: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
window.lintrk.q.push(arguments);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function will do the following things:
|
|
||||||
*
|
|
||||||
* 1. Canonicalize the given email by Reddit's rule: lowercase, trim,
|
|
||||||
* remove dots, remove everything after the first '+'.
|
|
||||||
* 2. Hash the canonicalized email by SHA256.
|
|
||||||
*/
|
|
||||||
export const hashEmail = async (email?: string) => {
|
|
||||||
if (!email) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitEmail = email.toLocaleLowerCase().trim().split('@');
|
|
||||||
const [localPart, domain] = splitEmail;
|
|
||||||
|
|
||||||
if (!localPart || !domain || splitEmail.length > 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/prefer-string-replace-all
|
|
||||||
const canonicalizedEmail = `${localPart.replace(/\./g, '').replace(/\+.*/, '')}@${domain}`;
|
|
||||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalizedEmail));
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/40031688/javascript-arraybuffer-to-hex
|
|
||||||
return [...new Uint8Array(hash)].map((value) => value.toString(16).padStart(2, '0')).join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const redditPixelId = 't2_ggt11omdo';
|
|
||||||
|
|
||||||
/** Report Reddit conversion events. */
|
|
||||||
export function rdt(..._: unknown[]) {
|
|
||||||
trySafe(() => {
|
|
||||||
if (!window.rdt) {
|
|
||||||
window.rdt = { callQueue: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
window.rdt.callQueue.push(arguments);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* eslint-enable @silverhand/fp/no-mutation, @silverhand/fp/no-mutating-methods, prefer-rest-params */
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { yes } from '@silverhand/essentials';
|
import { yes } from '@silverhand/essentials';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unused-modules
|
|
||||||
export const isProduction = process.env.NODE_ENV === 'production';
|
export const isProduction = process.env.NODE_ENV === 'production';
|
||||||
export const isCloud = yes(process.env.IS_CLOUD);
|
export const isCloud = yes(process.env.IS_CLOUD);
|
||||||
export const adminEndpoint = process.env.ADMIN_ENDPOINT;
|
export const adminEndpoint = process.env.ADMIN_ENDPOINT;
|
||||||
|
|
11
packages/console/src/include.d/tags.d.ts
vendored
11
packages/console/src/include.d/tags.d.ts
vendored
|
@ -1,13 +1,6 @@
|
||||||
declare interface Window {
|
declare interface Window {
|
||||||
// `gtag.js`
|
// `gtag.js`
|
||||||
dataLayer?: unknown[];
|
gtag?: (...args: unknown[]) => void;
|
||||||
// LinkedIn
|
|
||||||
_linkedin_data_partner_ids?: unknown[];
|
|
||||||
lintrk?: {
|
|
||||||
q: unknown[];
|
|
||||||
};
|
|
||||||
// Reddit
|
// Reddit
|
||||||
rdt?: {
|
rdt?: (...args: unknown[]) => void;
|
||||||
callQueue: unknown[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,14 @@ import { useContext, useEffect } from 'react';
|
||||||
import { Route, Navigate, Outlet, Routes } from 'react-router-dom';
|
import { Route, Navigate, Outlet, Routes } from 'react-router-dom';
|
||||||
import { SWRConfig } from 'swr';
|
import { SWRConfig } from 'swr';
|
||||||
|
|
||||||
import ReportConversion from '@/components/ReportConversion';
|
import { useReportConversion } from '@/components/Conversion';
|
||||||
|
import { GtagConversionId } from '@/components/Conversion/utils';
|
||||||
import AppBoundary from '@/containers/AppBoundary';
|
import AppBoundary from '@/containers/AppBoundary';
|
||||||
import ProtectedRoutes from '@/containers/ProtectedRoutes';
|
import ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||||
import TenantAccess from '@/containers/TenantAccess';
|
import TenantAccess from '@/containers/TenantAccess';
|
||||||
import { AppThemeContext } from '@/contexts/AppThemeProvider';
|
import { AppThemeContext } from '@/contexts/AppThemeProvider';
|
||||||
import Toast from '@/ds-components/Toast';
|
import Toast from '@/ds-components/Toast';
|
||||||
|
import useCurrentUser from '@/hooks/use-current-user';
|
||||||
import useSwrOptions from '@/hooks/use-swr-options';
|
import useSwrOptions from '@/hooks/use-swr-options';
|
||||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
import NotFound from '@/pages/NotFound';
|
import NotFound from '@/pages/NotFound';
|
||||||
|
@ -30,6 +32,23 @@ function Layout() {
|
||||||
const { setThemeOverride } = useContext(AppThemeContext);
|
const { setThemeOverride } = useContext(AppThemeContext);
|
||||||
const { match, getTo } = useTenantPathname();
|
const { match, getTo } = useTenantPathname();
|
||||||
|
|
||||||
|
// User object should be available at this point as it's rendered by the `<AppRoutes />`
|
||||||
|
// component in `packages/console/src/App.tsx`.
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a sign-up conversion.
|
||||||
|
*
|
||||||
|
* Note it may run multiple times (e.g. a user visit multiple times to finish the onboarding process,
|
||||||
|
* which rarely happens). We should turn on deduplication settings in the provider's dashboard. For
|
||||||
|
* example, in Google, we should set conversion's "Count" to "One".
|
||||||
|
*/
|
||||||
|
useReportConversion({
|
||||||
|
gtagId: GtagConversionId.SignUp,
|
||||||
|
redditType: 'SignUp',
|
||||||
|
transactionId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setThemeOverride(Theme.Light);
|
setThemeOverride(Theme.Light);
|
||||||
|
|
||||||
|
@ -53,7 +72,6 @@ function Layout() {
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<SWRConfig value={swrOptions}>
|
<SWRConfig value={swrOptions}>
|
||||||
<AppBoundary>
|
<AppBoundary>
|
||||||
<ReportConversion />
|
|
||||||
<Toast />
|
<Toast />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppBoundary>
|
</AppBoundary>
|
||||||
|
|
|
@ -9,6 +9,7 @@ import useSWR from 'swr';
|
||||||
|
|
||||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
import AppLoading from '@/components/AppLoading';
|
import AppLoading from '@/components/AppLoading';
|
||||||
|
import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils';
|
||||||
import PlanName from '@/components/PlanName';
|
import PlanName from '@/components/PlanName';
|
||||||
import { checkoutStateQueryKey } from '@/consts/subscriptions';
|
import { checkoutStateQueryKey } from '@/consts/subscriptions';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
@ -91,12 +92,20 @@ function CheckoutSuccessCallback() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No need to check `isDowngrade` here, since a downgrade must occur in a tenant with a Pro
|
||||||
|
// plan, and the purchase conversion has already been reported using the same tenant ID. We
|
||||||
|
// use the tenant ID as the transaction ID, so there's no concern about duplicate conversion
|
||||||
|
// reports.
|
||||||
|
reportToGoogle(GtagConversionId.PurchaseProPlan, { transactionId: checkoutTenantId });
|
||||||
|
|
||||||
|
// If the tenant is the current tenant, navigate to the callback page
|
||||||
if (checkoutTenantId === currentTenantId) {
|
if (checkoutTenantId === currentTenantId) {
|
||||||
navigate(conditional(callbackPage) ?? consoleHomePage, { replace: true });
|
navigate(conditional(callbackPage) ?? consoleHomePage, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: the tenant is created after checkout.
|
// New tenant created, navigate to the new tenant page
|
||||||
|
reportToGoogle(GtagConversionId.CreateProductionTenant, { transactionId: checkoutTenantId });
|
||||||
navigateTenant(checkoutTenantId);
|
navigateTenant(checkoutTenantId);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|
Loading…
Reference in a new issue