feat: whyrating - initial project from turbostarter boilerplate
This commit is contained in:
4
packages/analytics/web/eslint.config.js
Normal file
4
packages/analytics/web/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@turbostarter/eslint-config/base";
|
||||
import reactConfig from "@turbostarter/eslint-config/react";
|
||||
|
||||
export default [...baseConfig, ...reactConfig];
|
||||
39
packages/analytics/web/package.json
Normal file
39
packages/analytics/web/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@turbostarter/analytics-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./env": "./src/env.ts",
|
||||
"./server": "./src/server.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@turbostarter/eslint-config": "workspace:*",
|
||||
"@turbostarter/prettier-config": "workspace:*",
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"@openpanel/nextjs": "1.0.9",
|
||||
"@turbostarter/analytics": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"@vemetric/node": "0.2.0",
|
||||
"@vemetric/react": "0.6.1",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"mixpanel": "0.18.1",
|
||||
"mixpanel-browser": "2.71.1",
|
||||
"posthog-js": "1.283.0",
|
||||
"posthog-node": "5.11.0",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/analytics/web/src/env.ts
Normal file
1
packages/analytics/web/src/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./providers/env";
|
||||
1
packages/analytics/web/src/index.tsx
Normal file
1
packages/analytics/web/src/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { Provider, track, identify, reset } from "./providers";
|
||||
1
packages/analytics/web/src/providers/env.ts
Normal file
1
packages/analytics/web/src/providers/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./posthog/env";
|
||||
27
packages/analytics/web/src/providers/google-analytics/env.ts
Normal file
27
packages/analytics/web/src/providers/google-analytics/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "google-analytics",
|
||||
client: {
|
||||
NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID: z.string(),
|
||||
},
|
||||
server: {
|
||||
GOOGLE_ANALYTICS_SECRET: z.string(),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID:
|
||||
process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: unknown[];
|
||||
gtag?: (...args: unknown[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<script
|
||||
async
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID}`}
|
||||
onLoad={() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dataLayer = window.dataLayer ?? [];
|
||||
|
||||
function gtag(...args: unknown[]) {
|
||||
window.dataLayer?.push(args);
|
||||
}
|
||||
|
||||
window.gtag = gtag;
|
||||
|
||||
window.gtag("js", new Date());
|
||||
window.gtag(
|
||||
"config",
|
||||
env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
track: (event, data) => {
|
||||
if (typeof window === "undefined" || !window.gtag) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag("event", event, data);
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined" || !window.gtag) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag("config", env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID, {
|
||||
user_id: userId,
|
||||
...traits,
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
if (typeof window === "undefined" || !window.gtag) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.gtag("config", env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID, {
|
||||
user_id: null,
|
||||
});
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type {
|
||||
AllowedPropertyValues,
|
||||
AnalyticsProviderServerStrategy,
|
||||
} from "@turbostarter/analytics";
|
||||
|
||||
const postEvent = async (
|
||||
event: string,
|
||||
data?: Record<string, AllowedPropertyValues>,
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`https://www.google-analytics.com/mp/collect?measurement_id=${env.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID}&api_secret=${env.GOOGLE_ANALYTICS_SECRET}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_id: data?.clientId ?? randomUUID(),
|
||||
events: [{ name: event, params: data }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error("Failed to post event to Google Analytics: ", response);
|
||||
}
|
||||
};
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
void postEvent(event, data);
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
1
packages/analytics/web/src/providers/index.tsx
Normal file
1
packages/analytics/web/src/providers/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./posthog";
|
||||
26
packages/analytics/web/src/providers/mixpanel/env.ts
Normal file
26
packages/analytics/web/src/providers/mixpanel/env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig, NodeEnv } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "mixpanel",
|
||||
client: {
|
||||
NEXT_PUBLIC_MIXPANEL_TOKEN: z.string(),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
shared: {
|
||||
NODE_ENV: z.enum(NodeEnv).default(NodeEnv.DEVELOPMENT),
|
||||
},
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_MIXPANEL_TOKEN: process.env.NEXT_PUBLIC_MIXPANEL_TOKEN,
|
||||
},
|
||||
});
|
||||
51
packages/analytics/web/src/providers/mixpanel/index.tsx
Normal file
51
packages/analytics/web/src/providers/mixpanel/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import mixpanel from "mixpanel-browser";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { NodeEnv } from "@turbostarter/shared/constants";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
const init = () => {
|
||||
mixpanel.init(env.NEXT_PUBLIC_MIXPANEL_TOKEN, {
|
||||
debug: env.NODE_ENV === NodeEnv.DEVELOPMENT,
|
||||
autocapture: true,
|
||||
persistence: "localStorage",
|
||||
});
|
||||
};
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
return children;
|
||||
},
|
||||
track: (event, properties) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
mixpanel.track(event, properties);
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
mixpanel.identify(userId);
|
||||
if (traits) {
|
||||
mixpanel.people.set(traits);
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
mixpanel.reset();
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
33
packages/analytics/web/src/providers/mixpanel/server.ts
Normal file
33
packages/analytics/web/src/providers/mixpanel/server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import Mixpanel from "mixpanel";
|
||||
|
||||
import { NodeEnv } from "@turbostarter/shared/constants";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
let client: Mixpanel.Mixpanel | null = null;
|
||||
|
||||
const getClient = () => {
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
|
||||
client = Mixpanel.init(env.NEXT_PUBLIC_MIXPANEL_TOKEN, {
|
||||
debug: env.NODE_ENV === NodeEnv.DEVELOPMENT,
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export const { track } = {
|
||||
track: (event, properties) => {
|
||||
try {
|
||||
const mixpanel = getClient();
|
||||
mixpanel.track(event, properties ?? {});
|
||||
} catch (error) {
|
||||
logger.warn("Failed to track Mixpanel event: ", error);
|
||||
}
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
27
packages/analytics/web/src/providers/open-panel/env.ts
Normal file
27
packages/analytics/web/src/providers/open-panel/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "open-panel",
|
||||
client: {
|
||||
NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID: z.string(),
|
||||
},
|
||||
server: {
|
||||
OPEN_PANEL_SECRET: z.string(),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID:
|
||||
process.env.NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID,
|
||||
},
|
||||
});
|
||||
45
packages/analytics/web/src/providers/open-panel/index.tsx
Normal file
45
packages/analytics/web/src/providers/open-panel/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<OpenPanelComponent
|
||||
clientId={env.NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID}
|
||||
trackScreenViews
|
||||
trackAttributes
|
||||
trackOutgoingLinks
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
track: (event, data) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.op("track", event, data);
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.op("identify", {
|
||||
profileId: userId,
|
||||
...traits,
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.op("clear");
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
28
packages/analytics/web/src/providers/open-panel/server.ts
Normal file
28
packages/analytics/web/src/providers/open-panel/server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { OpenPanel } from "@openpanel/nextjs";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
let client: OpenPanel | null = null;
|
||||
|
||||
const getClient = () => {
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
|
||||
client = new OpenPanel({
|
||||
clientId: env.NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID,
|
||||
clientSecret: env.OPEN_PANEL_SECRET,
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
const client = getClient();
|
||||
|
||||
void client.track(event, data);
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
26
packages/analytics/web/src/providers/plausible/env.ts
Normal file
26
packages/analytics/web/src/providers/plausible/env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "plausible",
|
||||
clientPrefix: "NEXT_PUBLIC_",
|
||||
client: {
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: z.string(),
|
||||
NEXT_PUBLIC_PLAUSIBLE_HOST: z.string(),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN,
|
||||
NEXT_PUBLIC_PLAUSIBLE_HOST: process.env.NEXT_PUBLIC_PLAUSIBLE_HOST,
|
||||
},
|
||||
});
|
||||
109
packages/analytics/web/src/providers/plausible/index.tsx
Normal file
109
packages/analytics/web/src/providers/plausible/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type {
|
||||
AllowedPropertyValues,
|
||||
AnalyticsProviderClientStrategy,
|
||||
} from "@turbostarter/analytics";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (
|
||||
event: string,
|
||||
options?: { props?: Record<string, unknown> },
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
USER_ID: "plausible_user_id",
|
||||
USER_TRAITS: "plausible_user_traits",
|
||||
} as const;
|
||||
|
||||
const ValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
||||
const TraitsSchema = z.record(z.string(), ValueSchema);
|
||||
|
||||
const getStoredIdentity = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return { userId: undefined, traits: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = localStorage.getItem(STORAGE_KEYS.USER_ID) ?? undefined;
|
||||
const traitsStr = localStorage.getItem(STORAGE_KEYS.USER_TRAITS);
|
||||
|
||||
let traits: Record<string, AllowedPropertyValues> | undefined;
|
||||
if (traitsStr) {
|
||||
const parsed = TraitsSchema.safeParse(JSON.parse(traitsStr));
|
||||
if (parsed.success) {
|
||||
traits = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
return { userId, traits };
|
||||
} catch {
|
||||
return { userId: undefined, traits: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<script
|
||||
defer
|
||||
data-domain={env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||
src={`${env.NEXT_PUBLIC_PLAUSIBLE_HOST}/js/script.js`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
track: (event, data) => {
|
||||
if (typeof window === "undefined" || !window.plausible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId, traits } = getStoredIdentity();
|
||||
|
||||
const props: Record<string, unknown> = {
|
||||
...traits,
|
||||
...data,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
props.userId = userId;
|
||||
}
|
||||
|
||||
window.plausible(event, {
|
||||
props,
|
||||
});
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_ID, userId);
|
||||
if (traits) {
|
||||
localStorage.setItem(STORAGE_KEYS.USER_TRAITS, JSON.stringify(traits));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_ID);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_TRAITS);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
42
packages/analytics/web/src/providers/plausible/server.ts
Normal file
42
packages/analytics/web/src/providers/plausible/server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
const url = typeof data?.url === "string" ? data.url : "app://server-side";
|
||||
const referrer =
|
||||
typeof data?.referrer === "string" ? data.referrer : undefined;
|
||||
const ip = typeof data?.ip === "string" ? data.ip : undefined;
|
||||
|
||||
const props = data
|
||||
? Object.fromEntries(
|
||||
Object.entries(data).filter(
|
||||
([key]) => !["url", "referrer", "ip"].includes(key),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
void fetch(`${env.NEXT_PUBLIC_PLAUSIBLE_HOST}/api/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "TurboStarter-Server/1.0 (Server-side tracking)",
|
||||
...(ip && { "X-Forwarded-For": ip }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain: env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN,
|
||||
name: event,
|
||||
url: url,
|
||||
...(referrer && { referrer }),
|
||||
...(props && Object.keys(props).length > 0 && { props }),
|
||||
}),
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
logger.error("Failed to post event to Plausible: ", res);
|
||||
}
|
||||
});
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
29
packages/analytics/web/src/providers/posthog/env.ts
Normal file
29
packages/analytics/web/src/providers/posthog/env.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "posthog",
|
||||
clientPrefix: "NEXT_PUBLIC_",
|
||||
client: {
|
||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("https://us.i.posthog.com"),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
},
|
||||
});
|
||||
71
packages/analytics/web/src/providers/posthog/index.tsx
Normal file
71
packages/analytics/web/src/providers/posthog/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
const PageView = dynamic(
|
||||
() => import("./page-view").then((mod) => mod.PageView),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isValidPosthogConfig =
|
||||
env.NEXT_PUBLIC_POSTHOG_KEY &&
|
||||
env.NEXT_PUBLIC_POSTHOG_KEY !== "notyet" &&
|
||||
env.NEXT_PUBLIC_POSTHOG_HOST.startsWith("http");
|
||||
|
||||
if (typeof window !== "undefined" && isValidPosthogConfig) {
|
||||
posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY!, {
|
||||
api_host: env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
person_profiles: "always",
|
||||
capture_pageview: false,
|
||||
disable_external_dependency_loading: true,
|
||||
disable_session_recording: true,
|
||||
});
|
||||
}
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
// Skip PostHog wrapper entirely when not configured
|
||||
if (!isValidPosthogConfig) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostHogProvider client={posthog}>
|
||||
{children}
|
||||
<Suspense fallback={null}>
|
||||
<PageView />
|
||||
</Suspense>
|
||||
</PostHogProvider>
|
||||
);
|
||||
},
|
||||
track: (event, properties) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture(event, properties);
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.identify(userId, traits);
|
||||
},
|
||||
reset: () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.reset();
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
25
packages/analytics/web/src/providers/posthog/page-view.tsx
Normal file
25
packages/analytics/web/src/providers/posthog/page-view.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const PageView = () => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture("$pageview", {
|
||||
$current_url: url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams, posthog]);
|
||||
|
||||
return null;
|
||||
};
|
||||
41
packages/analytics/web/src/providers/posthog/server.ts
Normal file
41
packages/analytics/web/src/providers/posthog/server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
const isValidPosthogConfig =
|
||||
env.NEXT_PUBLIC_POSTHOG_KEY &&
|
||||
env.NEXT_PUBLIC_POSTHOG_KEY !== "notyet" &&
|
||||
env.NEXT_PUBLIC_POSTHOG_HOST.startsWith("http");
|
||||
|
||||
let client: PostHog | null = null;
|
||||
|
||||
const getClient = () => {
|
||||
if (!isValidPosthogConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
|
||||
client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
host: env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
|
||||
client.capture({
|
||||
event,
|
||||
distinctId: typeof data?.distinctId === "string" ? data.distinctId : "",
|
||||
properties: data,
|
||||
});
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
1
packages/analytics/web/src/providers/server.ts
Normal file
1
packages/analytics/web/src/providers/server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./posthog/server";
|
||||
29
packages/analytics/web/src/providers/umami/env.ts
Normal file
29
packages/analytics/web/src/providers/umami/env.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "umami",
|
||||
client: {
|
||||
NEXT_PUBLIC_UMAMI_HOST: z.string(),
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string(),
|
||||
},
|
||||
server: {
|
||||
UMAMI_API_HOST: z.string(),
|
||||
UMAMI_API_KEY: z.string().optional(),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_UMAMI_HOST: process.env.NEXT_PUBLIC_UMAMI_HOST,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
},
|
||||
});
|
||||
47
packages/analytics/web/src/providers/umami/index.tsx
Normal file
47
packages/analytics/web/src/providers/umami/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, unknown>) => void;
|
||||
identify: (
|
||||
userId?: string | Record<string, unknown>,
|
||||
traits?: Record<string, unknown>,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<script
|
||||
async
|
||||
src={`${env.NEXT_PUBLIC_UMAMI_HOST}/script.js`}
|
||||
data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
></script>
|
||||
</>
|
||||
);
|
||||
},
|
||||
track: (event, data) => {
|
||||
if (typeof window === "undefined" || !window.umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.umami.track(event, data);
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined" || !window.umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.umami.identify(userId, traits);
|
||||
},
|
||||
reset: () => {
|
||||
// Umami does not explicitly support resetting the session via the client-side API
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
45
packages/analytics/web/src/providers/umami/server.ts
Normal file
45
packages/analytics/web/src/providers/umami/server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
const hostname =
|
||||
typeof data?.hostname === "string" ? data.hostname : undefined;
|
||||
const language =
|
||||
typeof data?.language === "string" ? data.language : undefined;
|
||||
const referrer =
|
||||
typeof data?.referrer === "string" ? data.referrer : undefined;
|
||||
const screen = typeof data?.screen === "string" ? data.screen : undefined;
|
||||
const title = typeof data?.title === "string" ? data.title : undefined;
|
||||
const url = typeof data?.url === "string" ? data.url : "app://server-side";
|
||||
|
||||
void fetch(`${env.UMAMI_API_HOST}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-umami-api-key": env.UMAMI_API_KEY ?? "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "event",
|
||||
payload: {
|
||||
website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
name: event,
|
||||
url: url,
|
||||
...(hostname && { hostname }),
|
||||
...(language && { language }),
|
||||
...(referrer && { referrer }),
|
||||
...(screen && { screen }),
|
||||
...(title && { title }),
|
||||
data,
|
||||
},
|
||||
}),
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
logger.error("Failed to post event to Umami: ", res);
|
||||
}
|
||||
});
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
24
packages/analytics/web/src/providers/vemetric/env.ts
Normal file
24
packages/analytics/web/src/providers/vemetric/env.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "vemetric",
|
||||
client: {
|
||||
NEXT_PUBLIC_VEMETRIC_PROJECT_TOKEN: z.string(),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_VEMETRIC_PROJECT_TOKEN:
|
||||
process.env.NEXT_PUBLIC_VEMETRIC_PROJECT_TOKEN,
|
||||
},
|
||||
});
|
||||
49
packages/analytics/web/src/providers/vemetric/index.tsx
Normal file
49
packages/analytics/web/src/providers/vemetric/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { VemetricScript, vemetric } from "@vemetric/react";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<VemetricScript
|
||||
token={env.NEXT_PUBLIC_VEMETRIC_PROJECT_TOKEN}
|
||||
trackPageViews
|
||||
trackOutboundLinks
|
||||
trackDataAttributes
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
},
|
||||
track: (event, data) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
void vemetric.trackEvent(event, {
|
||||
eventData: data,
|
||||
});
|
||||
},
|
||||
identify: (userId, traits) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
void vemetric.identify({
|
||||
identifier: userId,
|
||||
data: {
|
||||
set: traits,
|
||||
},
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
void vemetric.resetUser();
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
30
packages/analytics/web/src/providers/vemetric/server.ts
Normal file
30
packages/analytics/web/src/providers/vemetric/server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Vemetric } from "@vemetric/node";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
let client: Vemetric | null = null;
|
||||
|
||||
const getClient = () => {
|
||||
if (client) {
|
||||
return client;
|
||||
}
|
||||
|
||||
client = new Vemetric({
|
||||
token: env.NEXT_PUBLIC_VEMETRIC_PROJECT_TOKEN,
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
const client = getClient();
|
||||
|
||||
void client.trackEvent(event, {
|
||||
userIdentifier: data?.distinctId?.toString() ?? "anonymous",
|
||||
eventData: data,
|
||||
});
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
15
packages/analytics/web/src/providers/vercel/env.ts
Normal file
15
packages/analytics/web/src/providers/vercel/env.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineEnv } from "envin";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "vercel",
|
||||
server: {},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
22
packages/analytics/web/src/providers/vercel/index.tsx
Normal file
22
packages/analytics/web/src/providers/vercel/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { track as trackEvent } from "@vercel/analytics";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
import type { AnalyticsProviderClientStrategy } from "@turbostarter/analytics";
|
||||
|
||||
export const { Provider, track, identify, reset } = {
|
||||
Provider: ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Analytics />
|
||||
</>
|
||||
);
|
||||
},
|
||||
track: trackEvent,
|
||||
identify: () => {
|
||||
// Vercel Web Analytics doesn't expose identify() on the client
|
||||
},
|
||||
reset: () => {
|
||||
// Vercel Web Analytics doesn't expose reset() on the client
|
||||
},
|
||||
} satisfies AnalyticsProviderClientStrategy;
|
||||
9
packages/analytics/web/src/providers/vercel/server.ts
Normal file
9
packages/analytics/web/src/providers/vercel/server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { track as vercelTrack } from "@vercel/analytics/server";
|
||||
|
||||
import type { AnalyticsProviderServerStrategy } from "@turbostarter/analytics";
|
||||
|
||||
export const { track } = {
|
||||
track: (event, data) => {
|
||||
void vercelTrack(event, data);
|
||||
},
|
||||
} satisfies AnalyticsProviderServerStrategy;
|
||||
1
packages/analytics/web/src/server.ts
Normal file
1
packages/analytics/web/src/server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { track } from "./providers/server";
|
||||
9
packages/analytics/web/tsconfig.json
Normal file
9
packages/analytics/web/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@turbostarter/tsconfig/internal.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom"],
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["*.ts", "src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user