Deploy to production - ensure all endpoints operational
This commit is contained in:
+83
-83
@@ -1,84 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Hash-based routing hook
|
||||
export const useHashRoute = () => {
|
||||
const parseRoute = (): string => window.location.hash?.slice(1) || "/"
|
||||
|
||||
const [route, setRoute] = useState<string>(parseRoute())
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = (): void => setRoute(parseRoute())
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
|
||||
const navigate = (newRoute: string): void => {
|
||||
window.location.hash = newRoute
|
||||
}
|
||||
|
||||
return { route, navigate }
|
||||
}
|
||||
|
||||
// Local storage hook with TypeScript support
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)): void => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
|
||||
// Media query hook
|
||||
export const useMediaQuery = (query: string): boolean => {
|
||||
const [matches, setMatches] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query)
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches)
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent): void => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
media.addEventListener('change', listener)
|
||||
return () => media.removeEventListener('change', listener)
|
||||
}, [matches, query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// Debounce hook
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Hash-based routing hook
|
||||
export const useHashRoute = () => {
|
||||
const parseRoute = (): string => window.location.hash?.slice(1) || "/"
|
||||
|
||||
const [route, setRoute] = useState<string>(parseRoute())
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = (): void => setRoute(parseRoute())
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
|
||||
const navigate = (newRoute: string): void => {
|
||||
window.location.hash = newRoute
|
||||
}
|
||||
|
||||
return { route, navigate }
|
||||
}
|
||||
|
||||
// Local storage hook with TypeScript support
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)): void => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
|
||||
// Media query hook
|
||||
export const useMediaQuery = (query: string): boolean => {
|
||||
const [matches, setMatches] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query)
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches)
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent): void => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
media.addEventListener('change', listener)
|
||||
return () => media.removeEventListener('change', listener)
|
||||
}, [matches, query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// Debounce hook
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
+107
-107
@@ -1,108 +1,108 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Types
|
||||
export interface ImpactCalculation {
|
||||
students: number
|
||||
families: number
|
||||
backpacks: number
|
||||
clothing: number
|
||||
emergency: number
|
||||
annual: {
|
||||
students: number
|
||||
families: number
|
||||
totalImpact: string
|
||||
}
|
||||
}
|
||||
|
||||
// Custom hook for donation impact calculations
|
||||
export const useDonationImpact = (amount: number): ImpactCalculation => {
|
||||
const [impact, setImpact] = useState<ImpactCalculation>({
|
||||
students: 0,
|
||||
families: 0,
|
||||
backpacks: 0,
|
||||
clothing: 0,
|
||||
emergency: 0,
|
||||
annual: {
|
||||
students: 0,
|
||||
families: 0,
|
||||
totalImpact: '0 students supported annually'
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const calculateImpact = (donationAmount: number): ImpactCalculation => {
|
||||
const students = Math.floor(donationAmount / 25) // $25 per student for basic supplies
|
||||
const families = Math.floor(donationAmount / 50) // $50 per family for comprehensive support
|
||||
const backpacks = Math.floor(donationAmount / 30) // $30 for complete backpack kit
|
||||
const clothing = Math.floor(donationAmount / 45) // $45 for clothing items
|
||||
const emergency = Math.floor(donationAmount / 75) // $75 for emergency assistance
|
||||
|
||||
return {
|
||||
students,
|
||||
families,
|
||||
backpacks,
|
||||
clothing,
|
||||
emergency,
|
||||
annual: {
|
||||
students: Math.floor((donationAmount * 12) / 25),
|
||||
families: Math.floor((donationAmount * 12) / 50),
|
||||
totalImpact: `${Math.floor((donationAmount * 12) / 25)} students supported annually`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setImpact(calculateImpact(amount))
|
||||
}, [amount])
|
||||
|
||||
return impact
|
||||
}
|
||||
|
||||
// Hook for form validation
|
||||
export const useFormValidation = <T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
validationRules: Record<keyof T, (value: any) => string | null>
|
||||
) => {
|
||||
const [values, setValues] = useState<T>(initialValues)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
|
||||
|
||||
const validate = (fieldName?: keyof T): boolean => {
|
||||
const fieldsToValidate = fieldName ? [fieldName] : Object.keys(validationRules) as (keyof T)[]
|
||||
const newErrors: Partial<Record<keyof T, string>> = { ...errors }
|
||||
let isValid = true
|
||||
|
||||
fieldsToValidate.forEach((field) => {
|
||||
const error = validationRules[field](values[field])
|
||||
if (error) {
|
||||
newErrors[field] = error
|
||||
isValid = false
|
||||
} else {
|
||||
delete newErrors[field]
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return isValid
|
||||
}
|
||||
|
||||
const setValue = (fieldName: keyof T, value: any): void => {
|
||||
setValues(prev => ({ ...prev, [fieldName]: value }))
|
||||
setTouched(prev => ({ ...prev, [fieldName]: true }))
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
setValues(initialValues)
|
||||
setErrors({})
|
||||
setTouched({})
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
setValue,
|
||||
validate,
|
||||
resetForm,
|
||||
isValid: Object.keys(errors).length === 0
|
||||
}
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Types
|
||||
export interface ImpactCalculation {
|
||||
students: number
|
||||
families: number
|
||||
backpacks: number
|
||||
clothing: number
|
||||
emergency: number
|
||||
annual: {
|
||||
students: number
|
||||
families: number
|
||||
totalImpact: string
|
||||
}
|
||||
}
|
||||
|
||||
// Custom hook for donation impact calculations
|
||||
export const useDonationImpact = (amount: number): ImpactCalculation => {
|
||||
const [impact, setImpact] = useState<ImpactCalculation>({
|
||||
students: 0,
|
||||
families: 0,
|
||||
backpacks: 0,
|
||||
clothing: 0,
|
||||
emergency: 0,
|
||||
annual: {
|
||||
students: 0,
|
||||
families: 0,
|
||||
totalImpact: '0 students supported annually'
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const calculateImpact = (donationAmount: number): ImpactCalculation => {
|
||||
const students = Math.floor(donationAmount / 25) // $25 per student for basic supplies
|
||||
const families = Math.floor(donationAmount / 50) // $50 per family for comprehensive support
|
||||
const backpacks = Math.floor(donationAmount / 30) // $30 for complete backpack kit
|
||||
const clothing = Math.floor(donationAmount / 45) // $45 for clothing items
|
||||
const emergency = Math.floor(donationAmount / 75) // $75 for emergency assistance
|
||||
|
||||
return {
|
||||
students,
|
||||
families,
|
||||
backpacks,
|
||||
clothing,
|
||||
emergency,
|
||||
annual: {
|
||||
students: Math.floor((donationAmount * 12) / 25),
|
||||
families: Math.floor((donationAmount * 12) / 50),
|
||||
totalImpact: `${Math.floor((donationAmount * 12) / 25)} students supported annually`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setImpact(calculateImpact(amount))
|
||||
}, [amount])
|
||||
|
||||
return impact
|
||||
}
|
||||
|
||||
// Hook for form validation
|
||||
export const useFormValidation = <T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
validationRules: Record<keyof T, (value: any) => string | null>
|
||||
) => {
|
||||
const [values, setValues] = useState<T>(initialValues)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
|
||||
|
||||
const validate = (fieldName?: keyof T): boolean => {
|
||||
const fieldsToValidate = fieldName ? [fieldName] : Object.keys(validationRules) as (keyof T)[]
|
||||
const newErrors: Partial<Record<keyof T, string>> = { ...errors }
|
||||
let isValid = true
|
||||
|
||||
fieldsToValidate.forEach((field) => {
|
||||
const error = validationRules[field](values[field])
|
||||
if (error) {
|
||||
newErrors[field] = error
|
||||
isValid = false
|
||||
} else {
|
||||
delete newErrors[field]
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return isValid
|
||||
}
|
||||
|
||||
const setValue = (fieldName: keyof T, value: any): void => {
|
||||
setValues(prev => ({ ...prev, [fieldName]: value }))
|
||||
setTouched(prev => ({ ...prev, [fieldName]: true }))
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
setValues(initialValues)
|
||||
setErrors({})
|
||||
setTouched({})
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
setValue,
|
||||
validate,
|
||||
resetForm,
|
||||
isValid: Object.keys(errors).length === 0
|
||||
}
|
||||
}
|
||||
+224
-224
@@ -1,225 +1,225 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface PerformanceMetrics {
|
||||
FCP: number | null // First Contentful Paint
|
||||
LCP: number | null // Largest Contentful Paint
|
||||
FID: number | null // First Input Delay
|
||||
CLS: number | null // Cumulative Layout Shift
|
||||
TTFB: number | null // Time to First Byte
|
||||
}
|
||||
|
||||
interface WebVitals {
|
||||
metrics: PerformanceMetrics
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Performance monitoring hook
|
||||
export function usePerformance(): WebVitals {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
||||
FCP: null,
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: null,
|
||||
TTFB: null
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const updateMetric = useCallback((name: keyof PerformanceMetrics, value: number) => {
|
||||
setMetrics(prev => ({ ...prev, [name]: value }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Web Vitals API is available
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Measure TTFB from Navigation Timing API
|
||||
const measureTTFB = () => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
||||
if (navigation) {
|
||||
const ttfb = navigation.responseStart - navigation.requestStart
|
||||
updateMetric('TTFB', ttfb)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FCP using PerformanceObserver
|
||||
const measureFCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByName('first-contentful-paint')
|
||||
if (entries.length > 0) {
|
||||
updateMetric('FCP', entries[0].startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure LCP using PerformanceObserver
|
||||
const measureLCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1]
|
||||
updateMetric('LCP', lastEntry.startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('LCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FID using PerformanceObserver
|
||||
const measureFID = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.name === 'first-input') {
|
||||
const fid = entry.processingStart - entry.startTime
|
||||
updateMetric('FID', fid)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['first-input'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FID measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure CLS using PerformanceObserver
|
||||
const measureCLS = () => {
|
||||
try {
|
||||
let clsValue = 0
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value
|
||||
updateMetric('CLS', clsValue)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['layout-shift'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('CLS measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize measurements
|
||||
measureTTFB()
|
||||
const cleanupFCP = measureFCP()
|
||||
const cleanupLCP = measureLCP()
|
||||
const cleanupFID = measureFID()
|
||||
const cleanupCLS = measureCLS()
|
||||
|
||||
// Set loading to false after initial measurements
|
||||
const timeout = setTimeout(() => setIsLoading(false), 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
cleanupFCP()
|
||||
cleanupLCP()
|
||||
cleanupFID()
|
||||
cleanupCLS()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { metrics, isLoading }
|
||||
}
|
||||
|
||||
// Hook for monitoring bundle size and loading performance
|
||||
export function useBundlePerformance() {
|
||||
const [bundleMetrics, setBundleMetrics] = useState({
|
||||
totalSize: 0,
|
||||
jsSize: 0,
|
||||
cssSize: 0,
|
||||
imageSize: 0,
|
||||
loadTime: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByType('resource')
|
||||
let jsSize = 0
|
||||
let cssSize = 0
|
||||
let imageSize = 0
|
||||
let totalSize = 0
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
const size = entry.encodedBodySize || entry.transferSize || 0
|
||||
totalSize += size
|
||||
|
||||
if (entry.name.includes('.js')) jsSize += size
|
||||
else if (entry.name.includes('.css')) cssSize += size
|
||||
else if (entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) imageSize += size
|
||||
})
|
||||
|
||||
setBundleMetrics({
|
||||
totalSize: Math.round(totalSize / 1024), // KB
|
||||
jsSize: Math.round(jsSize / 1024),
|
||||
cssSize: Math.round(cssSize / 1024),
|
||||
imageSize: Math.round(imageSize / 1024),
|
||||
loadTime: performance.now()
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe({ entryTypes: ['resource'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return bundleMetrics
|
||||
}
|
||||
|
||||
// Analytics tracking for performance metrics
|
||||
export function trackPerformanceMetrics(metrics: PerformanceMetrics) {
|
||||
if (typeof window === 'undefined' || !(window as any).gtag) return
|
||||
|
||||
const { FCP, LCP, FID, CLS, TTFB } = metrics
|
||||
|
||||
// Send to Google Analytics
|
||||
if (FCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FCP',
|
||||
value: Math.round(FCP)
|
||||
})
|
||||
|
||||
if (LCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'LCP',
|
||||
value: Math.round(LCP)
|
||||
})
|
||||
|
||||
if (FID) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FID',
|
||||
value: Math.round(FID)
|
||||
})
|
||||
|
||||
if (CLS) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'CLS',
|
||||
value: Math.round(CLS * 1000) // Convert to ms
|
||||
})
|
||||
|
||||
if (TTFB) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'TTFB',
|
||||
value: Math.round(TTFB)
|
||||
})
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group('Performance Metrics:')
|
||||
console.table(metrics)
|
||||
console.groupEnd()
|
||||
}
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface PerformanceMetrics {
|
||||
FCP: number | null // First Contentful Paint
|
||||
LCP: number | null // Largest Contentful Paint
|
||||
FID: number | null // First Input Delay
|
||||
CLS: number | null // Cumulative Layout Shift
|
||||
TTFB: number | null // Time to First Byte
|
||||
}
|
||||
|
||||
interface WebVitals {
|
||||
metrics: PerformanceMetrics
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Performance monitoring hook
|
||||
export function usePerformance(): WebVitals {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
||||
FCP: null,
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: null,
|
||||
TTFB: null
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const updateMetric = useCallback((name: keyof PerformanceMetrics, value: number) => {
|
||||
setMetrics(prev => ({ ...prev, [name]: value }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Web Vitals API is available
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Measure TTFB from Navigation Timing API
|
||||
const measureTTFB = () => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
||||
if (navigation) {
|
||||
const ttfb = navigation.responseStart - navigation.requestStart
|
||||
updateMetric('TTFB', ttfb)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FCP using PerformanceObserver
|
||||
const measureFCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByName('first-contentful-paint')
|
||||
if (entries.length > 0) {
|
||||
updateMetric('FCP', entries[0].startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure LCP using PerformanceObserver
|
||||
const measureLCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1]
|
||||
updateMetric('LCP', lastEntry.startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('LCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FID using PerformanceObserver
|
||||
const measureFID = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.name === 'first-input') {
|
||||
const fid = entry.processingStart - entry.startTime
|
||||
updateMetric('FID', fid)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['first-input'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FID measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure CLS using PerformanceObserver
|
||||
const measureCLS = () => {
|
||||
try {
|
||||
let clsValue = 0
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value
|
||||
updateMetric('CLS', clsValue)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['layout-shift'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('CLS measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize measurements
|
||||
measureTTFB()
|
||||
const cleanupFCP = measureFCP()
|
||||
const cleanupLCP = measureLCP()
|
||||
const cleanupFID = measureFID()
|
||||
const cleanupCLS = measureCLS()
|
||||
|
||||
// Set loading to false after initial measurements
|
||||
const timeout = setTimeout(() => setIsLoading(false), 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
cleanupFCP()
|
||||
cleanupLCP()
|
||||
cleanupFID()
|
||||
cleanupCLS()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { metrics, isLoading }
|
||||
}
|
||||
|
||||
// Hook for monitoring bundle size and loading performance
|
||||
export function useBundlePerformance() {
|
||||
const [bundleMetrics, setBundleMetrics] = useState({
|
||||
totalSize: 0,
|
||||
jsSize: 0,
|
||||
cssSize: 0,
|
||||
imageSize: 0,
|
||||
loadTime: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByType('resource')
|
||||
let jsSize = 0
|
||||
let cssSize = 0
|
||||
let imageSize = 0
|
||||
let totalSize = 0
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
const size = entry.encodedBodySize || entry.transferSize || 0
|
||||
totalSize += size
|
||||
|
||||
if (entry.name.includes('.js')) jsSize += size
|
||||
else if (entry.name.includes('.css')) cssSize += size
|
||||
else if (entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) imageSize += size
|
||||
})
|
||||
|
||||
setBundleMetrics({
|
||||
totalSize: Math.round(totalSize / 1024), // KB
|
||||
jsSize: Math.round(jsSize / 1024),
|
||||
cssSize: Math.round(cssSize / 1024),
|
||||
imageSize: Math.round(imageSize / 1024),
|
||||
loadTime: performance.now()
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe({ entryTypes: ['resource'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return bundleMetrics
|
||||
}
|
||||
|
||||
// Analytics tracking for performance metrics
|
||||
export function trackPerformanceMetrics(metrics: PerformanceMetrics) {
|
||||
if (typeof window === 'undefined' || !(window as any).gtag) return
|
||||
|
||||
const { FCP, LCP, FID, CLS, TTFB } = metrics
|
||||
|
||||
// Send to Google Analytics
|
||||
if (FCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FCP',
|
||||
value: Math.round(FCP)
|
||||
})
|
||||
|
||||
if (LCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'LCP',
|
||||
value: Math.round(LCP)
|
||||
})
|
||||
|
||||
if (FID) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FID',
|
||||
value: Math.round(FID)
|
||||
})
|
||||
|
||||
if (CLS) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'CLS',
|
||||
value: Math.round(CLS * 1000) // Convert to ms
|
||||
})
|
||||
|
||||
if (TTFB) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'TTFB',
|
||||
value: Math.round(TTFB)
|
||||
})
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group('Performance Metrics:')
|
||||
console.table(metrics)
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user