feat(ui): 营销站 UI/UE/UX 治理 P0→P3 收官
复评(impeccable critique)全部 Priority Issues 与 Minor Observations 落地: - P0 数字口径:MetricsBasisNote 单一真源 + metrics-basis.test fs 防线,接入 products/solutions/services/home/about;纯渲染端加角注,不动 DB/seed 值 - P1 转化断头路:敬请期待态 + 404 语境出口 + services 空态双出口 - P1 首页并卡:TrustSection 早鸟横幅并入 CasesSection 单张深色共创卡 - P2 CTA 治理:CONSULT_CTA_ALLOWLIST 白名单 + 逐字符扫描器,/contact CTA 全归一 - P3 polish:footer 补 服务/公司列、contact 死三元/空槽/toast、GlobalErrorTracker 幂等日志 - 文档同源:PRODUCT/CONTEXT 动效带统一 180–280ms;DESIGN.md 立 The 10px Floor; font-floor.test 守卫禁止再引入 <10px(保留 10/11px 有意 Bain 微标签) Gates: type-check 0 · jest 1581/132 · lint 0e/126w · contrast 7/7 · headings 10/10
This commit is contained in:
@@ -19,6 +19,9 @@ function shouldIgnoreError(message: string): boolean {
|
||||
return IGNORED_ERRORS.some((pattern) => pattern.test(message));
|
||||
}
|
||||
|
||||
// StrictMode 下 effect 双跑会让开发日志重复;用模块级标志保证只打一次。
|
||||
let initLogEmitted = false;
|
||||
|
||||
export function GlobalErrorTracker() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -66,7 +69,8 @@ export function GlobalErrorTracker() {
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
document.addEventListener('error', handleResourceError, true);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV === 'development' && !initLogEmitted) {
|
||||
initLogEmitted = true;
|
||||
console.log('[GA4] Global error tracker initialized');
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jest.mock('next/script', () => {
|
||||
const MockScript = (props: { id?: string; src?: string; children?: string; strategy?: string; [key: string]: unknown }) => {
|
||||
const { children, src, ...rest } = props;
|
||||
if (children) {
|
||||
return <script data-testid="next-script" data-inline="true" {...rest} />;
|
||||
return <script data-testid="next-script" data-inline="true" {...rest} dangerouslySetInnerHTML={{ __html: children }} />;
|
||||
}
|
||||
return <script data-testid="next-script" src={src} {...rest} />;
|
||||
};
|
||||
@@ -87,36 +87,87 @@ describe('GoogleAnalytics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gtag configuration', () => {
|
||||
it('should call gtag config on mount with pathname and searchParams', () => {
|
||||
describe('single gtag initialization', () => {
|
||||
it('should send exactly one config from the inline snippet, with default pageview enabled', () => {
|
||||
setupGtag();
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
const { container } = render(<GoogleAnalytics />);
|
||||
|
||||
const inline = container.querySelector('script[data-inline="true"]');
|
||||
const snippet = inline?.textContent ?? '';
|
||||
expect(snippet).toContain("gtag('config', 'G-TEST123'");
|
||||
expect(snippet.match(/gtag\('config'/g)).toHaveLength(1);
|
||||
// 复评 P2:snippet config 承担首屏 pageview,不得再出现 send_page_view:false + effect 双发
|
||||
expect(snippet).not.toContain('send_page_view');
|
||||
});
|
||||
|
||||
it('should NOT call config on initial mount (snippet config owns the load pageview)', () => {
|
||||
setupGtag();
|
||||
mockPathname.mockReturnValue('/products');
|
||||
mockSearchParams.mockReturnValue(new URLSearchParams('page=1'));
|
||||
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
|
||||
page_path: '/products?page=1',
|
||||
page_title: document.title,
|
||||
page_location: window.location.origin + '/products?page=1',
|
||||
});
|
||||
expect(gtagMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call gtag config on mount without searchParams when empty', () => {
|
||||
it('should call config only when navigating to a different URL', () => {
|
||||
setupGtag();
|
||||
mockPathname.mockReturnValue('/about');
|
||||
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
render(<GoogleAnalytics />);
|
||||
const { rerender } = render(<GoogleAnalytics />);
|
||||
expect(gtagMock).not.toHaveBeenCalled();
|
||||
|
||||
mockPathname.mockReturnValue('/contact');
|
||||
act(() => {
|
||||
rerender(<GoogleAnalytics />);
|
||||
});
|
||||
|
||||
expect(gtagMock).toHaveBeenCalledTimes(1);
|
||||
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
|
||||
page_path: '/about',
|
||||
page_path: '/contact',
|
||||
page_title: document.title,
|
||||
page_location: window.location.origin + '/about',
|
||||
page_location: window.location.origin + '/contact',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include searchParams in navigation page_path', () => {
|
||||
setupGtag();
|
||||
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
const { rerender } = render(<GoogleAnalytics />);
|
||||
|
||||
mockPathname.mockReturnValue('/news');
|
||||
mockSearchParams.mockReturnValue(new URLSearchParams('page=2'));
|
||||
act(() => {
|
||||
rerender(<GoogleAnalytics />);
|
||||
});
|
||||
|
||||
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
|
||||
page_path: '/news?page=2',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should skip duplicate config when URL is unchanged across re-renders', () => {
|
||||
setupGtag();
|
||||
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
const { rerender } = render(<GoogleAnalytics />);
|
||||
|
||||
mockPathname.mockReturnValue('/cases');
|
||||
act(() => {
|
||||
rerender(<GoogleAnalytics />);
|
||||
});
|
||||
expect(gtagMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
rerender(<GoogleAnalytics />);
|
||||
});
|
||||
expect(gtagMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering guards', () => {
|
||||
it('should not call gtag when gtag is not available on window', () => {
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
render(<GoogleAnalytics />);
|
||||
@@ -125,7 +176,7 @@ describe('GoogleAnalytics', () => {
|
||||
|
||||
it('should not call gtag when GA_MEASUREMENT_ID is empty', () => {
|
||||
// The component checks for GA_MEASUREMENT_ID at the top level
|
||||
// When it's empty, the component returns null and no scripts render
|
||||
// When it is empty, the component returns null and no scripts render
|
||||
setupGtag();
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
const { container } = render(<GoogleAnalytics />);
|
||||
@@ -135,29 +186,4 @@ describe('GoogleAnalytics', () => {
|
||||
expect(scripts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pathname changes', () => {
|
||||
it('should re-call gtag config when pathname changes', () => {
|
||||
setupGtag();
|
||||
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
const { rerender } = render(<GoogleAnalytics />);
|
||||
|
||||
// Initial call
|
||||
expect(gtagMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change pathname
|
||||
mockPathname.mockReturnValue('/contact');
|
||||
act(() => {
|
||||
rerender(<GoogleAnalytics />);
|
||||
});
|
||||
|
||||
expect(gtagMock).toHaveBeenCalledTimes(2);
|
||||
expect(gtagMock).toHaveBeenLastCalledWith('config', 'G-TEST123', {
|
||||
page_path: '/contact',
|
||||
page_title: document.title,
|
||||
page_location: window.location.origin + '/contact',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Script from 'next/script';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, Suspense, useSyncExternalStore } from 'react';
|
||||
import { useEffect, useRef, Suspense, useSyncExternalStore } from 'react';
|
||||
|
||||
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||
|
||||
@@ -21,12 +21,22 @@ function GoogleAnalyticsContent() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const mounted = useIsMounted();
|
||||
// null = 尚未导航过:首屏 pageview 由 snippet 内唯一一次 config 发送(GA 用真实 URL)。
|
||||
// effect 只在「路由变化且 URL 与上次不同」时再发 config,消除复评指出的双 config。
|
||||
const lastNavigationUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;}
|
||||
|
||||
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
|
||||
|
||||
if (lastNavigationUrlRef.current === null) {
|
||||
lastNavigationUrlRef.current = url;
|
||||
return;
|
||||
}
|
||||
if (lastNavigationUrlRef.current === url) {return;}
|
||||
lastNavigationUrlRef.current = url;
|
||||
|
||||
if (window.gtag) {
|
||||
window.gtag('config', GA_MEASUREMENT_ID, {
|
||||
page_path: url,
|
||||
@@ -60,7 +70,6 @@ function GoogleAnalyticsContent() {
|
||||
});
|
||||
|
||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||
send_page_view: false,
|
||||
anonymize_ip: true,
|
||||
allow_google_signals: true,
|
||||
allow_ad_personalization_signals: false,
|
||||
|
||||
Reference in New Issue
Block a user