This commit is contained in:
2025-09-30 23:10:13 +02:00
parent a646542d8f
commit 9938c1f9e2
27 changed files with 2158 additions and 498 deletions

89
src/hooks/use-countup.ts Normal file
View 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
View 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,
]);
};