SEO/AEO
This commit is contained in:
89
src/hooks/use-countup.ts
Normal file
89
src/hooks/use-countup.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseCountUpOptions {
|
||||
end: number;
|
||||
duration?: number;
|
||||
decimals?: number;
|
||||
startOnInView?: boolean;
|
||||
}
|
||||
|
||||
export const useCountUp = ({ end, duration = 2000, decimals = 0, startOnInView = true }: UseCountUpOptions) => {
|
||||
const [count, setCount] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const elementRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Intersection Observer for triggering animation when element comes into view
|
||||
useEffect(() => {
|
||||
if (!startOnInView) {
|
||||
setHasStarted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !hasStarted) {
|
||||
setIsVisible(true);
|
||||
setHasStarted(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (elementRef.current) {
|
||||
observer.observe(elementRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [startOnInView, hasStarted]);
|
||||
|
||||
// Counter animation
|
||||
useEffect(() => {
|
||||
if (!hasStarted) return;
|
||||
|
||||
let startTime: number;
|
||||
let animationFrame: number;
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!startTime) startTime = currentTime;
|
||||
const progress = Math.min((currentTime - startTime) / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
||||
const currentCount = end * easeOutCubic;
|
||||
|
||||
setCount(currentCount);
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
}, [end, duration, hasStarted]);
|
||||
|
||||
const formattedCount = count.toFixed(decimals);
|
||||
|
||||
return { count: formattedCount, elementRef };
|
||||
};
|
||||
|
||||
// Utility function to parse numbers from strings like "150+", "99.9%", "<2min"
|
||||
export const parseNumberFromString = (value: string): number => {
|
||||
const numericMatch = value.match(/(\d+\.?\d*)/);
|
||||
return numericMatch ? parseFloat(numericMatch[1]) : 0;
|
||||
};
|
||||
|
||||
// Utility function to format the final value with original suffix/prefix
|
||||
export const formatWithOriginalString = (originalValue: string, animatedNumber: string): string => {
|
||||
const numericMatch = originalValue.match(/(\d+\.?\d*)/);
|
||||
if (!numericMatch) return originalValue;
|
||||
|
||||
const originalNumber = numericMatch[1];
|
||||
return originalValue.replace(originalNumber, animatedNumber);
|
||||
};
|
||||
132
src/hooks/useSEO.ts
Normal file
132
src/hooks/useSEO.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface SEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
ogImage?: string;
|
||||
twitterTitle?: string;
|
||||
twitterDescription?: string;
|
||||
twitterImage?: string;
|
||||
canonical?: string;
|
||||
type?: 'website' | 'article' | 'product' | 'profile';
|
||||
author?: string;
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing SEO meta tags dynamically
|
||||
* Centralizes SEO logic for all pages in the application
|
||||
*/
|
||||
export const useSEO = ({
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
ogImage,
|
||||
twitterTitle,
|
||||
twitterDescription,
|
||||
twitterImage,
|
||||
canonical,
|
||||
type = 'website',
|
||||
author,
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
}: SEOProps) => {
|
||||
useEffect(() => {
|
||||
// Update page title
|
||||
document.title = title;
|
||||
|
||||
// Helper function to update or create meta tags
|
||||
const updateMetaTag = (selector: string, attribute: string, content: string) => {
|
||||
let element = document.querySelector(selector);
|
||||
if (element) {
|
||||
element.setAttribute(attribute, content);
|
||||
} else {
|
||||
element = document.createElement('meta');
|
||||
if (selector.startsWith('meta[name=')) {
|
||||
element.setAttribute('name', selector.match(/name="([^"]+)"/)?.[1] || '');
|
||||
} else if (selector.startsWith('meta[property=')) {
|
||||
element.setAttribute('property', selector.match(/property="([^"]+)"/)?.[1] || '');
|
||||
}
|
||||
element.setAttribute(attribute, content);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
};
|
||||
|
||||
// Update description
|
||||
updateMetaTag('meta[name="description"]', 'content', description);
|
||||
|
||||
// Update keywords if provided
|
||||
if (keywords) {
|
||||
updateMetaTag('meta[name="keywords"]', 'content', keywords);
|
||||
}
|
||||
|
||||
// Update author if provided
|
||||
if (author) {
|
||||
updateMetaTag('meta[name="author"]', 'content', author);
|
||||
}
|
||||
|
||||
// Update Open Graph tags
|
||||
updateMetaTag('meta[property="og:title"]', 'content', ogTitle || title);
|
||||
updateMetaTag('meta[property="og:description"]', 'content', ogDescription || description);
|
||||
updateMetaTag('meta[property="og:type"]', 'content', type);
|
||||
|
||||
if (ogImage) {
|
||||
updateMetaTag('meta[property="og:image"]', 'content', ogImage);
|
||||
}
|
||||
|
||||
// Update canonical URL
|
||||
if (canonical) {
|
||||
let linkElement = document.querySelector('link[rel="canonical"]');
|
||||
if (linkElement) {
|
||||
linkElement.setAttribute('href', canonical);
|
||||
} else {
|
||||
linkElement = document.createElement('link');
|
||||
linkElement.setAttribute('rel', 'canonical');
|
||||
linkElement.setAttribute('href', canonical);
|
||||
document.head.appendChild(linkElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Update Twitter Card tags
|
||||
updateMetaTag('meta[name="twitter:title"]', 'content', twitterTitle || ogTitle || title);
|
||||
updateMetaTag('meta[name="twitter:description"]', 'content', twitterDescription || ogDescription || description);
|
||||
|
||||
if (twitterImage || ogImage) {
|
||||
updateMetaTag('meta[name="twitter:image"]', 'content', twitterImage || ogImage || '');
|
||||
}
|
||||
|
||||
// Article-specific tags
|
||||
if (type === 'article') {
|
||||
if (publishedTime) {
|
||||
updateMetaTag('meta[property="article:published_time"]', 'content', publishedTime);
|
||||
}
|
||||
if (modifiedTime) {
|
||||
updateMetaTag('meta[property="article:modified_time"]', 'content', modifiedTime);
|
||||
}
|
||||
if (author) {
|
||||
updateMetaTag('meta[property="article:author"]', 'content', author);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
ogImage,
|
||||
twitterTitle,
|
||||
twitterDescription,
|
||||
twitterImage,
|
||||
canonical,
|
||||
type,
|
||||
author,
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user