hosted: add posthog opt out + session replay
This commit is contained in:
parent
3e39c65265
commit
2130dccc81
1
drizzle/0010_high_liz_osborn.sql
Normal file
1
drizzle/0010_high_liz_osborn.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user` ADD `analytics_opted_out` integer;
|
||||||
2038
drizzle/meta/0010_snapshot.json
Normal file
2038
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -71,6 +71,13 @@
|
|||||||
"when": 1776347182067,
|
"when": 1776347182067,
|
||||||
"tag": "0009_smart_kitty_pryde",
|
"tag": "0009_smart_kitty_pryde",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777422740449,
|
||||||
|
"tag": "0010_high_liz_osborn",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
CircleHelp,
|
CircleHelp,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Menu,
|
Menu,
|
||||||
|
Settings,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@ -13,7 +14,6 @@ import {
|
|||||||
MissingSeoSetupModal,
|
MissingSeoSetupModal,
|
||||||
SeoApiStatusBanners,
|
SeoApiStatusBanners,
|
||||||
} from "@/client/layout/AppShellParts";
|
} from "@/client/layout/AppShellParts";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
|
||||||
import { getProjectNavGroups } from "@/client/navigation/items";
|
import { getProjectNavGroups } from "@/client/navigation/items";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
@ -322,7 +322,9 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
|||||||
>
|
>
|
||||||
{email ? (
|
{email ? (
|
||||||
<li className="menu-title max-w-full">
|
<li className="menu-title max-w-full">
|
||||||
<span className="truncate text-base-content">{email}</span>
|
<span className="truncate text-base-content" data-ph-mask>
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{mobileOnly ? (
|
{mobileOnly ? (
|
||||||
@ -341,6 +343,12 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
|
<li>
|
||||||
|
<Link to="/settings" className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
{isHostedMode && email ? (
|
{isHostedMode && email ? (
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@ -352,7 +360,6 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
<ThemePreferenceMenuItems />
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type BrowserPostHogClient = typeof import("posthog-js").default;
|
|||||||
let browserPostHogClientPromise: Promise<BrowserPostHogClient | null> | null =
|
let browserPostHogClientPromise: Promise<BrowserPostHogClient | null> | null =
|
||||||
null;
|
null;
|
||||||
let browserPostHogInitialized = false;
|
let browserPostHogInitialized = false;
|
||||||
|
let analyticsCaptureEnabled = true;
|
||||||
|
|
||||||
function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||||
if (typeof window === "undefined" || !isHostedClientAuthMode()) {
|
if (typeof window === "undefined" || !isHostedClientAuthMode()) {
|
||||||
@ -34,6 +35,11 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
|||||||
defaults: "2026-01-30",
|
defaults: "2026-01-30",
|
||||||
capture_exceptions: true,
|
capture_exceptions: true,
|
||||||
capture_pageview: "history_change",
|
capture_pageview: "history_change",
|
||||||
|
respect_dnt: true,
|
||||||
|
session_recording: {
|
||||||
|
maskAllInputs: true,
|
||||||
|
maskTextSelector: "[data-ph-mask], .ph-mask",
|
||||||
|
},
|
||||||
sanitize_properties(properties, event) {
|
sanitize_properties(properties, event) {
|
||||||
if (event === "$pageview" || event === "$pageleave") {
|
if (event === "$pageview" || event === "$pageleave") {
|
||||||
const url: unknown = properties["$current_url"];
|
const url: unknown = properties["$current_url"];
|
||||||
@ -63,10 +69,6 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
|||||||
return browserPostHogClientPromise;
|
return browserPostHogClientPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initPostHog() {
|
|
||||||
void getBrowserPostHogClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
||||||
void getBrowserPostHogClient().then((client) => {
|
void getBrowserPostHogClient().then((client) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
@ -78,10 +80,23 @@ function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withExistingPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
||||||
|
if (!browserPostHogClientPromise) return;
|
||||||
|
void browserPostHogClientPromise.then((client) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
fn(client);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("posthog operation failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function captureClientEvent(
|
export function captureClientEvent(
|
||||||
event: string,
|
event: string,
|
||||||
properties?: Record<string, unknown>,
|
properties?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
|
if (!analyticsCaptureEnabled) return;
|
||||||
withPostHogClient((client) => client.capture(event, properties));
|
withPostHogClient((client) => client.capture(event, properties));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +104,7 @@ export function identifyAnalyticsUser(args: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
organizationId: string | null;
|
organizationId: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
if (!analyticsCaptureEnabled) return;
|
||||||
withPostHogClient((client) => {
|
withPostHogClient((client) => {
|
||||||
client.identify(args.userId);
|
client.identify(args.userId);
|
||||||
if (args.organizationId) {
|
if (args.organizationId) {
|
||||||
@ -98,13 +114,39 @@ export function identifyAnalyticsUser(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resetAnalyticsUser() {
|
export function resetAnalyticsUser() {
|
||||||
withPostHogClient((client) => client.reset());
|
withExistingPostHogClient((client) => {
|
||||||
|
client.stopSessionRecording();
|
||||||
|
client.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAnalyticsCapture() {
|
||||||
|
analyticsCaptureEnabled = false;
|
||||||
|
if (!browserPostHogInitialized || !browserPostHogClientPromise) return;
|
||||||
|
void browserPostHogClientPromise.then((client) => {
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
client.stopSessionRecording();
|
||||||
|
client.opt_out_capturing();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("posthog opt-out failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAnalyticsCapture() {
|
||||||
|
analyticsCaptureEnabled = true;
|
||||||
|
withPostHogClient((client) => {
|
||||||
|
client.opt_in_capturing();
|
||||||
|
client.startSessionRecording();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function captureClientError(
|
export function captureClientError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
properties: Record<string, string | null | undefined> = {},
|
properties: Record<string, string | null | undefined> = {},
|
||||||
) {
|
) {
|
||||||
|
if (!analyticsCaptureEnabled) return;
|
||||||
withPostHogClient((client) =>
|
withPostHogClient((client) =>
|
||||||
client.captureException(error, {
|
client.captureException(error, {
|
||||||
source: "client",
|
source: "client",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export const user = sqliteTable("user", {
|
|||||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = sqliteTable(
|
export const session = sqliteTable(
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { organizationClient } from "better-auth/client/plugins";
|
import {
|
||||||
|
inferAdditionalFields,
|
||||||
|
organizationClient,
|
||||||
|
} from "better-auth/client/plugins";
|
||||||
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||||
|
import { userAdditionalFields } from "@/lib/auth-options";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
plugins: [organizationClient()],
|
plugins: [
|
||||||
|
organizationClient(),
|
||||||
|
inferAdditionalFields({ user: userAdditionalFields }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession } = authClient;
|
export const { useSession } = authClient;
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
export const HOSTED_PASSWORD_MIN_LENGTH = 8;
|
export const HOSTED_PASSWORD_MIN_LENGTH = 8;
|
||||||
export const HOSTED_PASSWORD_MAX_LENGTH = 128;
|
export const HOSTED_PASSWORD_MAX_LENGTH = 128;
|
||||||
|
|
||||||
|
export const userAdditionalFields = {
|
||||||
|
analyticsOptedOut: {
|
||||||
|
type: "boolean" as const,
|
||||||
|
defaultValue: () => false,
|
||||||
|
required: false as const,
|
||||||
|
input: true as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const baseAuthOptions = {
|
export const baseAuthOptions = {
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -8,4 +17,7 @@ export const baseAuthOptions = {
|
|||||||
minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH,
|
minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH,
|
||||||
maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH,
|
maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: userAdditionalFields,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -94,6 +94,8 @@ function getTrustedOrigins(baseUrl: string) {
|
|||||||
trustedOrigins.push(
|
trustedOrigins.push(
|
||||||
"http://open-seo.localhost:1355",
|
"http://open-seo.localhost:1355",
|
||||||
"http://*.open-seo.localhost:1355",
|
"http://*.open-seo.localhost:1355",
|
||||||
|
"https://open-seo.localhost:1355",
|
||||||
|
"https://*.open-seo.localhost:1355",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authentica
|
|||||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||||
|
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
@ -97,6 +98,11 @@ const AppSupportRoute = AppSupportRouteImport.update({
|
|||||||
path: '/support',
|
path: '/support',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppSettingsRoute = AppSettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => AppRouteRoute,
|
||||||
|
} as any)
|
||||||
const AppBillingRoute = AppBillingRouteImport.update({
|
const AppBillingRoute = AppBillingRouteImport.update({
|
||||||
id: '/billing',
|
id: '/billing',
|
||||||
path: '/billing',
|
path: '/billing',
|
||||||
@ -208,6 +214,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
|
'/settings': typeof AppSettingsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
@ -237,6 +244,7 @@ export interface FileRoutesByTo {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
|
'/settings': typeof AppSettingsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
@ -267,6 +275,7 @@ export interface FileRoutesById {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/_app/billing': typeof AppBillingRoute
|
'/_app/billing': typeof AppBillingRoute
|
||||||
|
'/_app/settings': typeof AppSettingsRoute
|
||||||
'/_app/support': typeof AppSupportRoute
|
'/_app/support': typeof AppSupportRoute
|
||||||
'/_auth/sign-in': typeof AuthSignInRoute
|
'/_auth/sign-in': typeof AuthSignInRoute
|
||||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||||
@ -299,6 +308,7 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
@ -328,6 +338,7 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
@ -357,6 +368,7 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/_app/billing'
|
| '/_app/billing'
|
||||||
|
| '/_app/settings'
|
||||||
| '/_app/support'
|
| '/_app/support'
|
||||||
| '/_auth/sign-in'
|
| '/_auth/sign-in'
|
||||||
| '/_auth/sign-up'
|
| '/_auth/sign-up'
|
||||||
@ -480,6 +492,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppSupportRouteImport
|
preLoaderRoute: typeof AppSupportRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_app/settings': {
|
||||||
|
id: '/_app/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/settings'
|
||||||
|
preLoaderRoute: typeof AppSettingsRouteImport
|
||||||
|
parentRoute: typeof AppRouteRoute
|
||||||
|
}
|
||||||
'/_app/billing': {
|
'/_app/billing': {
|
||||||
id: '/_app/billing'
|
id: '/_app/billing'
|
||||||
path: '/billing'
|
path: '/billing'
|
||||||
@ -618,6 +637,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AppRouteRouteChildren {
|
interface AppRouteRouteChildren {
|
||||||
AppBillingRoute: typeof AppBillingRoute
|
AppBillingRoute: typeof AppBillingRoute
|
||||||
|
AppSettingsRoute: typeof AppSettingsRoute
|
||||||
AppSupportRoute: typeof AppSupportRoute
|
AppSupportRoute: typeof AppSupportRoute
|
||||||
AppIndexRoute: typeof AppIndexRoute
|
AppIndexRoute: typeof AppIndexRoute
|
||||||
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
|
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
|
||||||
@ -625,6 +645,7 @@ interface AppRouteRouteChildren {
|
|||||||
|
|
||||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||||
AppBillingRoute: AppBillingRoute,
|
AppBillingRoute: AppBillingRoute,
|
||||||
|
AppSettingsRoute: AppSettingsRoute,
|
||||||
AppSupportRoute: AppSupportRoute,
|
AppSupportRoute: AppSupportRoute,
|
||||||
AppIndexRoute: AppIndexRoute,
|
AppIndexRoute: AppIndexRoute,
|
||||||
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
|
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
|
||||||
|
|||||||
@ -14,8 +14,9 @@ import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
|||||||
import { themePreferenceInitScript } from "@/client/lib/theme";
|
import { themePreferenceInitScript } from "@/client/lib/theme";
|
||||||
import {
|
import {
|
||||||
identifyAnalyticsUser,
|
identifyAnalyticsUser,
|
||||||
initPostHog,
|
|
||||||
resetAnalyticsUser,
|
resetAnalyticsUser,
|
||||||
|
startAnalyticsCapture,
|
||||||
|
stopAnalyticsCapture,
|
||||||
} from "@/client/lib/posthog";
|
} from "@/client/lib/posthog";
|
||||||
import { NotFound } from "@/client/components/NotFound";
|
import { NotFound } from "@/client/components/NotFound";
|
||||||
import appCss from "@/client/styles/app.css?url";
|
import appCss from "@/client/styles/app.css?url";
|
||||||
@ -85,6 +86,7 @@ function PostHogBootstrap() {
|
|||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
const { data: session, isPending: isSessionPending } = useSession();
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
const userId = session?.user?.id ?? null;
|
const userId = session?.user?.id ?? null;
|
||||||
|
const optedOut = session?.user?.analyticsOptedOut === true;
|
||||||
const organizationId = getActiveOrganizationId(session);
|
const organizationId = getActiveOrganizationId(session);
|
||||||
const previousUserIdRef = React.useRef<string | null>(null);
|
const previousUserIdRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
@ -93,16 +95,17 @@ function PostHogBootstrap() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
initPostHog();
|
if (userId && !optedOut) {
|
||||||
|
startAnalyticsCapture();
|
||||||
if (userId) {
|
|
||||||
identifyAnalyticsUser({ userId, organizationId });
|
identifyAnalyticsUser({ userId, organizationId });
|
||||||
previousUserIdRef.current = userId;
|
previousUserIdRef.current = userId;
|
||||||
|
} else if (userId && optedOut) {
|
||||||
|
stopAnalyticsCapture();
|
||||||
} else if (previousUserIdRef.current) {
|
} else if (previousUserIdRef.current) {
|
||||||
previousUserIdRef.current = null;
|
previousUserIdRef.current = null;
|
||||||
resetAnalyticsUser();
|
resetAnalyticsUser();
|
||||||
}
|
}
|
||||||
}, [isHostedMode, isSessionPending, organizationId, userId]);
|
}, [isHostedMode, isSessionPending, optedOut, organizationId, userId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/routes/_app/settings.tsx
Normal file
119
src/routes/_app/settings.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||||
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_app/settings")({
|
||||||
|
component: SettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const THEME_OPTIONS: {
|
||||||
|
value: ThemePreference;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Sun;
|
||||||
|
}[] = [
|
||||||
|
{ value: "system", label: "System", icon: Monitor },
|
||||||
|
{ value: "light", label: "Light", icon: Sun },
|
||||||
|
{ value: "dark", label: "Dark", icon: Moon },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SettingsPage() {
|
||||||
|
const isHosted = isHostedClientAuthMode();
|
||||||
|
const { themePreference, setThemePreference } = useThemePreference();
|
||||||
|
const { data: session, isPending: isSessionPending } = useSession();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
|
||||||
|
|
||||||
|
async function updateAnalyticsPreference(enabled: boolean) {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.updateUser({
|
||||||
|
analyticsOptedOut: !enabled,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
toast.error("We couldn't update your analytics setting.");
|
||||||
|
} else {
|
||||||
|
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("We couldn't update your analytics setting.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
|
||||||
|
<div className="mx-auto max-w-xl space-y-10">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-base-content/50">
|
||||||
|
Appearance
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-sm">Theme</span>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Theme preference"
|
||||||
|
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const isActive = option.value === themePreference;
|
||||||
|
const Icon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-base-100 text-base-content shadow-sm"
|
||||||
|
: "text-base-content/50 hover:text-base-content/80"
|
||||||
|
}`}
|
||||||
|
onClick={() => setThemePreference(option.value)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isHosted ? (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-base-content/50">
|
||||||
|
Analytics
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">Help improve OpenSEO</p>
|
||||||
|
<p className="mt-1 text-sm text-base-content/60">
|
||||||
|
Share analytics and usage data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-primary"
|
||||||
|
checked={analyticsEnabled}
|
||||||
|
disabled={isSessionPending || isSaving || !session?.user}
|
||||||
|
onChange={(event) => {
|
||||||
|
void updateAnalyticsPreference(event.currentTarget.checked);
|
||||||
|
}}
|
||||||
|
aria-label="Enable product analytics"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowRight, User } from "lucide-react";
|
import { ArrowRight, Settings, User } from "lucide-react";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
@ -235,7 +235,15 @@ function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
|||||||
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<li className="menu-title max-w-full">
|
<li className="menu-title max-w-full">
|
||||||
<span className="truncate text-base-content">{email}</span>
|
<span className="truncate text-base-content" data-ph-mask>
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/settings" className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<ThemePreferenceMenuItems />
|
<ThemePreferenceMenuItems />
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user