421e204711
Memind CI / Test, build, and release guards (push) Successful in 3m28s
Ship the M成果 page with list/delete UX, fix deletePage for incomplete page records, route /space/achievements, and add M成果 under the WeChat M空间 menu. Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
8.7 KiB
TypeScript
271 lines
8.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
|
|
type AnalyticsIdentity = {
|
|
distinctId: string;
|
|
username: string;
|
|
ownerSegment: string;
|
|
planType: string;
|
|
channel: string;
|
|
surface: 'product';
|
|
identityMode: 'raw' | 'pseudonymous';
|
|
};
|
|
|
|
type ProductAnalyticsContext = {
|
|
enabled: boolean;
|
|
websiteId?: string;
|
|
scriptPath?: string;
|
|
hostPath?: string;
|
|
domains?: string;
|
|
identity?: AnalyticsIdentity | null;
|
|
};
|
|
|
|
type PageViewProperties = Record<string, unknown>;
|
|
type UmamiTracker = {
|
|
track: (
|
|
value?: string | ((properties: PageViewProperties) => PageViewProperties),
|
|
data?: Record<string, unknown>,
|
|
) => Promise<unknown> | unknown;
|
|
identify: (id: string, data?: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
umami?: UmamiTracker;
|
|
}
|
|
}
|
|
|
|
const SAFE_QUERY_KEYS = new Set(['category', 'utm_source', 'preview']);
|
|
let trackerLoad: Promise<void> | null = null;
|
|
let lastPageViewKey = '';
|
|
let lastIdentityKey = '';
|
|
|
|
export function normalizeProductRoute(pathname: string, search = '') {
|
|
const params = new URLSearchParams(search);
|
|
const safe = new URLSearchParams();
|
|
for (const key of SAFE_QUERY_KEYS) {
|
|
const value = params.get(key);
|
|
if (value) safe.set(key, value.slice(0, 100));
|
|
}
|
|
const query = safe.toString();
|
|
return `${pathname || '/'}${query ? `?${query}` : ''}`;
|
|
}
|
|
|
|
export function resolveProductRouteName(pathname: string) {
|
|
if (pathname === '/') return 'chat';
|
|
if (pathname === '/space') return 'mindspace_home';
|
|
if (pathname === '/space/achievements') return 'mindspace_achievements';
|
|
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
|
|
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
|
|
if (pathname === '/feedback') return 'feedback';
|
|
return 'other';
|
|
}
|
|
|
|
function safeTargetPath(element: Element) {
|
|
if (!(element instanceof HTMLAnchorElement) || !element.href) return '';
|
|
try {
|
|
const target = new URL(element.href, window.location.href);
|
|
return target.origin === window.location.origin
|
|
? target.pathname
|
|
: `external:${target.hostname}`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function actionName(element: Element) {
|
|
const stableClasses = Array.from(element.classList)
|
|
.filter((name) => /^[a-z][a-z0-9_-]{1,80}$/i.test(name))
|
|
.slice(0, 3)
|
|
.join('.');
|
|
return String(
|
|
element.getAttribute('data-analytics-action') ||
|
|
element.getAttribute('data-umami-event') ||
|
|
element.getAttribute('aria-label') ||
|
|
element.getAttribute('title') ||
|
|
element.id ||
|
|
stableClasses ||
|
|
element.tagName.toLowerCase(),
|
|
).slice(0, 100);
|
|
}
|
|
|
|
function waitForTracker() {
|
|
if (window.umami) return Promise.resolve();
|
|
return new Promise<void>((resolve, reject) => {
|
|
const startedAt = Date.now();
|
|
const timer = window.setInterval(() => {
|
|
if (window.umami) {
|
|
window.clearInterval(timer);
|
|
resolve();
|
|
} else if (Date.now() - startedAt > 5000) {
|
|
window.clearInterval(timer);
|
|
reject(new Error('Umami tracker did not initialize'));
|
|
}
|
|
}, 25);
|
|
});
|
|
}
|
|
|
|
function loadTracker(context: ProductAnalyticsContext) {
|
|
if (window.umami) return Promise.resolve();
|
|
if (trackerLoad) return trackerLoad;
|
|
trackerLoad = new Promise<void>((resolve, reject) => {
|
|
const existing = document.querySelector<HTMLScriptElement>('#memind-product-analytics');
|
|
if (existing) {
|
|
void waitForTracker().then(resolve, reject);
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.id = 'memind-product-analytics';
|
|
script.defer = true;
|
|
script.src = context.scriptPath || '/analytics/script.js';
|
|
script.dataset.websiteId = context.websiteId || '';
|
|
script.dataset.hostUrl = context.hostPath || '/analytics';
|
|
script.dataset.autoTrack = 'false';
|
|
if (context.domains) script.dataset.domains = context.domains;
|
|
script.addEventListener('load', () => void waitForTracker().then(resolve, reject), { once: true });
|
|
script.addEventListener('error', () => reject(new Error('Unable to load Umami tracker')), {
|
|
once: true,
|
|
});
|
|
document.head.appendChild(script);
|
|
}).catch((error) => {
|
|
trackerLoad = null;
|
|
throw error;
|
|
});
|
|
return trackerLoad;
|
|
}
|
|
|
|
function absoluteRoute(route: string) {
|
|
return new URL(route, window.location.origin).toString();
|
|
}
|
|
|
|
function trackPageView(route: string) {
|
|
return window.umami?.track((properties) => ({
|
|
...properties,
|
|
url: absoluteRoute(route),
|
|
title: document.title,
|
|
}));
|
|
}
|
|
|
|
function trackProductEvent(
|
|
eventName: string,
|
|
route: string,
|
|
identity: AnalyticsIdentity | null | undefined,
|
|
data: Record<string, unknown> = {},
|
|
) {
|
|
return window.umami?.track((properties) => ({
|
|
...properties,
|
|
name: eventName,
|
|
url: absoluteRoute(route),
|
|
title: document.title,
|
|
data: {
|
|
surface: 'product',
|
|
route,
|
|
route_name: resolveProductRouteName(window.location.pathname),
|
|
channel: identity?.channel || 'h5',
|
|
owner_segment: identity?.ownerSegment || 'anonymous',
|
|
plan_type: identity?.planType || 'anonymous',
|
|
identity_mode: identity?.identityMode || 'anonymous',
|
|
...data,
|
|
},
|
|
}));
|
|
}
|
|
|
|
export function useProductAnalytics(userId?: string | null) {
|
|
const location = useLocation();
|
|
const [context, setContext] = useState<ProductAnalyticsContext>({ enabled: false });
|
|
const [ready, setReady] = useState(false);
|
|
const contextRef = useRef(context);
|
|
const route = normalizeProductRoute(location.pathname, location.search);
|
|
const routeRef = useRef(route);
|
|
|
|
contextRef.current = context;
|
|
routeRef.current = route;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void fetch('/api/analytics/context', { credentials: 'same-origin', cache: 'no-store' })
|
|
.then(async (response) => (response.ok ? ((await response.json()) as ProductAnalyticsContext) : null))
|
|
.then((next) => {
|
|
if (!cancelled && next) setContext(next);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [userId]);
|
|
|
|
useEffect(() => {
|
|
if (!context.enabled || !context.websiteId) return;
|
|
let cancelled = false;
|
|
void loadTracker(context)
|
|
.then(() => {
|
|
if (cancelled) return;
|
|
const identity = context.identity;
|
|
if (identity?.distinctId) {
|
|
const identityKey = `${context.websiteId}:${identity.distinctId}`;
|
|
if (lastIdentityKey !== identityKey) {
|
|
lastIdentityKey = identityKey;
|
|
void window.umami?.identify(identity.distinctId, {
|
|
username: identity.username,
|
|
owner_segment: identity.ownerSegment,
|
|
plan_type: identity.planType,
|
|
channel: identity.channel,
|
|
surface: identity.surface,
|
|
identity_mode: identity.identityMode,
|
|
});
|
|
}
|
|
} else if (lastIdentityKey) {
|
|
lastIdentityKey = '';
|
|
void window.umami?.identify('', {
|
|
auth_state: 'anonymous',
|
|
channel: 'h5',
|
|
surface: 'product',
|
|
});
|
|
}
|
|
setReady(true);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [context]);
|
|
|
|
useEffect(() => {
|
|
if (!ready || !context.enabled || !context.websiteId) return;
|
|
const pageViewKey = `${context.websiteId}:${route}`;
|
|
if (lastPageViewKey === pageViewKey) return;
|
|
lastPageViewKey = pageViewKey;
|
|
void trackPageView(route);
|
|
|
|
const tenSecond = window.setTimeout(() => {
|
|
void trackProductEvent('product_engaged_10s', route, context.identity);
|
|
}, 10_000);
|
|
const thirtySecond = window.setTimeout(() => {
|
|
void trackProductEvent('product_engaged_30s', route, context.identity);
|
|
}, 30_000);
|
|
return () => {
|
|
window.clearTimeout(tenSecond);
|
|
window.clearTimeout(thirtySecond);
|
|
};
|
|
}, [context, ready, route]);
|
|
|
|
useEffect(() => {
|
|
if (!ready || !context.enabled) return;
|
|
const handleClick = (event: MouseEvent) => {
|
|
const target = event.target instanceof Element
|
|
? event.target.closest('a,button,[role="button"],[data-analytics-action],[data-umami-event]')
|
|
: null;
|
|
if (!target) return;
|
|
void trackProductEvent('product_click', routeRef.current, contextRef.current.identity, {
|
|
action: actionName(target),
|
|
element: target.tagName.toLowerCase(),
|
|
element_id: target.id.slice(0, 100),
|
|
element_class: Array.from(target.classList).slice(0, 3).join(' ').slice(0, 200),
|
|
target_path: safeTargetPath(target),
|
|
});
|
|
};
|
|
document.addEventListener('click', handleClick, { capture: true, passive: true });
|
|
return () => document.removeEventListener('click', handleClick, { capture: true });
|
|
}, [context.enabled, ready]);
|
|
}
|