chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from './lib/google-analytics';
export * from './lib/use-window-scroll-depth';
@@ -0,0 +1,42 @@
/**
* GA4 events are routed through GTM via dataLayer (no direct gtag calls here).
*/
declare global {
interface Window {
dataLayer?: Array<Record<string, unknown>>;
}
}
function pushGtmEvent(eventName: string, payload: Record<string, unknown>) {
if (process.env.NODE_ENV !== 'production') return;
if (typeof window === 'undefined') return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: eventName, ...payload });
}
export function sendPageViewEventViaGtm(data: {
path?: string;
title?: string;
}): void {
pushGtmEvent('page_view', {
...(data.path ? { page_path: data.path } : {}),
...(data.title ? { page_title: data.title } : {}),
});
}
export function sendCustomEventViaGtm(
action: string,
category: string,
label: string,
value?: number,
customObject?: Record<string, unknown>
): void {
// Preserve existing GA event names by using the action as the dataLayer event.
pushGtmEvent(action, {
event_category: category,
event_label: label,
...(value !== undefined ? { value } : {}),
...(customObject ?? {}),
});
}
+123
View File
@@ -0,0 +1,123 @@
export interface Gtag {
(
command: 'config',
targetId: string,
config?: ConfigParams | ControlParams | EventParams | CustomParams
): void;
(command: 'set', targetId: string, config: CustomParams | boolean): void;
(command: 'set', config: CustomParams): void;
(command: 'js', config: Date): void;
(
command: 'event',
eventName: EventNames | string,
eventParams?: ControlParams | EventParams | CustomParams
): void;
(
command: 'get',
targetId: string,
fieldName: FieldNames | string,
callback?: (field: string) => any
): void;
(
command: 'consent',
consentArg: ConsentArg | string,
consentParams: ConsentParams
): void;
}
interface CustomParams {
[key: string]: any;
}
interface ConfigParams {
page_location?: string;
page_path?: string;
page_title?: string;
send_page_view?: boolean;
}
interface ControlParams {
groups?: string | string[];
send_to?: string | string[];
event_callback?: () => void;
event_timeout?: number;
}
type EventNames =
| 'add_payment_info'
| 'add_to_cart'
| 'add_to_wishlist'
| 'begin_checkout'
| 'checkout_progress'
| 'exception'
| 'generate_lead'
| 'login'
| 'page_view'
| 'purchase'
| 'refund'
| 'remove_from_cart'
| 'screen_view'
| 'search'
| 'select_content'
| 'set_checkout_option'
| 'share'
| 'sign_up'
| 'timing_complete'
| 'view_item'
| 'view_item_list'
| 'view_promotion'
| 'view_search_results';
interface EventParams {
checkout_option?: string;
checkout_step?: number;
content_id?: string;
content_type?: string;
coupon?: string;
currency?: string;
description?: string;
fatal?: boolean;
items?: Item[];
method?: string;
number?: string;
promotions?: Promotion[];
screen_name?: string;
search_term?: string;
shipping?: Currency;
tax?: Currency;
transaction_id?: string;
value?: number;
event_label?: string;
event_category?: string;
}
type Currency = string | number;
interface Item {
brand?: string;
category?: string;
creative_name?: string;
creative_slot?: string;
id?: string;
location_id?: string;
name?: string;
price?: Currency;
quantity?: number;
}
interface Promotion {
creative_name?: string;
creative_slot?: string;
id?: string;
name?: string;
}
type FieldNames = 'client_id' | 'session_id' | 'gclid';
type ConsentArg = 'default' | 'update';
interface ConsentParams {
ad_storage?: 'granted' | 'denied';
analytics_storage?: 'granted' | 'denied';
wait_for_update?: number;
region?: string[];
}
@@ -0,0 +1,71 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { sendCustomEventViaGtm } from './google-analytics';
const SCROLL_THRESHOLDS = [10, 25, 50, 75, 90] as const;
export function useWindowScrollDepth(): void {
const pathname = usePathname();
const firedThresholds = useRef<Set<number>>(new Set());
const shouldTrackScroll = useRef(true);
const rafId = useRef<number | null>(null);
const handleScroll = useCallback(() => {
if (!shouldTrackScroll.current) return;
if (typeof window === 'undefined') return;
const scrollPercentage =
((window.scrollY + window.innerHeight) /
document.documentElement.scrollHeight) *
100;
// Fire events for all thresholds we've passed but haven't fired yet
for (const threshold of SCROLL_THRESHOLDS) {
if (
scrollPercentage >= threshold &&
!firedThresholds.current.has(threshold)
) {
firedThresholds.current.add(threshold);
sendCustomEventViaGtm(`scroll_${threshold}`, 'scroll', pathname || '/');
}
}
}, [pathname]);
const throttledHandleScroll = useCallback(() => {
if (rafId.current !== null) return;
rafId.current = requestAnimationFrame(() => {
handleScroll();
rafId.current = null;
});
}, [handleScroll]);
useEffect(() => {
shouldTrackScroll.current = false;
const timeout = setTimeout(() => {
firedThresholds.current = new Set();
shouldTrackScroll.current = true;
// Immediately check current scroll position to capture thresholds
// that may have been passed during the delay
handleScroll();
}, 500);
return () => clearTimeout(timeout);
}, [pathname, handleScroll]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.addEventListener('scroll', throttledHandleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', throttledHandleScroll);
if (rafId.current !== null) {
cancelAnimationFrame(rafId.current);
}
};
}, [throttledHandleScroll]);
}