Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
133 lines
3.7 KiB
TypeScript
133 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import { motion, useInView } from "motion/react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
|
|
|
interface TextDiffuseRevealProps {
|
|
text: string;
|
|
className?: string;
|
|
startDelay?: number;
|
|
once?: boolean;
|
|
amount?: number;
|
|
}
|
|
|
|
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
|
|
|
|
export function TextDiffuseReveal({
|
|
text,
|
|
className = "",
|
|
startDelay = 0,
|
|
once = true,
|
|
amount = 0.3,
|
|
}: Readonly<TextDiffuseRevealProps>) {
|
|
const ref = useRef<HTMLSpanElement | null>(null);
|
|
const shouldReduceMotion = useReducedMotion();
|
|
const isInView = useInView(ref, { once, amount });
|
|
const [staggerDelay, setStaggerDelay] = useState(0.03);
|
|
const [completedChars, setCompletedChars] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const media = window.matchMedia("(max-width: 768px)");
|
|
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
|
|
|
|
updateStagger();
|
|
media.addEventListener("change", updateStagger);
|
|
return () => media.removeEventListener("change", updateStagger);
|
|
}, []);
|
|
|
|
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
|
|
const animatedChars = useMemo(
|
|
() =>
|
|
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
|
|
[text],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isInView) {
|
|
setCompletedChars(0);
|
|
}
|
|
}, [isInView]);
|
|
|
|
const hasActiveWillChange =
|
|
!shouldReduceMotion && isInView && completedChars < animatedChars;
|
|
|
|
let animatedIndex = 0;
|
|
|
|
return (
|
|
<span
|
|
ref={ref}
|
|
className={className}
|
|
aria-label={text}
|
|
style={{
|
|
willChange: hasActiveWillChange
|
|
? "filter, opacity, transform"
|
|
: undefined,
|
|
}}
|
|
>
|
|
{tokens.map((token, tokenIndex) => {
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
if (token === "\n") {
|
|
return <br key={`line-${tokenIndex}`} />;
|
|
}
|
|
|
|
if (/^\s+$/.test(token)) {
|
|
return (
|
|
<span key={`space-${tokenIndex}`} aria-hidden="true">
|
|
{token.replace(/ /g, "\u00A0")}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
key={`word-${tokenIndex}`}
|
|
className="inline-block whitespace-nowrap"
|
|
>
|
|
{Array.from(token).map((char, charIndex) => {
|
|
const currentIndex = animatedIndex;
|
|
animatedIndex += 1;
|
|
|
|
return (
|
|
<motion.span
|
|
key={`char-${tokenIndex}-${charIndex}-${char}`}
|
|
className="inline-block"
|
|
initial={
|
|
shouldReduceMotion
|
|
? { opacity: 0 }
|
|
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
|
}
|
|
animate={
|
|
isInView
|
|
? { filter: "blur(0px)", opacity: 1, scale: 1 }
|
|
: shouldReduceMotion
|
|
? { filter: "blur(0px)", opacity: 0, scale: 1 }
|
|
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
|
}
|
|
transition={{
|
|
duration: shouldReduceMotion ? 0.3 : 0.6,
|
|
ease: EXPO_OUT,
|
|
delay: startDelay + currentIndex * staggerDelay,
|
|
}}
|
|
onAnimationComplete={() => {
|
|
if (!shouldReduceMotion) {
|
|
setCompletedChars((count) =>
|
|
count < animatedChars ? count + 1 : count,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{char}
|
|
</motion.span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
}
|