feat: implement Lenis smooth scroll and animated stat counters
- Add Lenis smooth scroll library (Landing page only) - Create SmoothScroll wrapper component with GSAP integration - Implement animated stat counters with scroll-triggered count-up - Add GSAP animations for stats section (40%, 5yrs, 15m) - Update package.json with lenis dependency
This commit is contained in:
+52
-49
@@ -10,6 +10,7 @@ import AdminSchedule from './pages/AdminSchedule';
|
||||
import CustomerProfile from './pages/CustomerProfile';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import SmoothScroll from './components/SmoothScroll';
|
||||
import { Toaster } from 'sonner';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
@@ -35,59 +36,61 @@ function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Toaster position="top-right" richColors closeButton expand={true} />
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<SmoothScroll>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Protected Field Agent Routes */}
|
||||
<Route path="/portal/profile" element={
|
||||
<ProtectedRoute allowedRoles={['CUSTOMER']}>
|
||||
<CustomerProfile />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Protected Field Agent Routes */}
|
||||
<Route path="/portal/profile" element={
|
||||
<ProtectedRoute allowedRoles={['CUSTOMER']}>
|
||||
<CustomerProfile />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route
|
||||
path="/emp/fa/dashboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emp/fa/maps"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
|
||||
<Maps />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emp/fa/dashboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emp/fa/maps"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
|
||||
<Maps />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Admin Routes */}
|
||||
<Route
|
||||
path="/admin/schedule"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
||||
<AdminSchedule />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/leaderboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
||||
<LeaderboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
{/* Protected Admin Routes */}
|
||||
<Route
|
||||
path="/admin/schedule"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
||||
<AdminSchedule />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Route
|
||||
path="/admin/leaderboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
||||
<LeaderboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</SmoothScroll>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Lenis from 'lenis';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
/**
|
||||
* SmoothScroll - Conditional Lenis smooth scroll wrapper
|
||||
*
|
||||
* Provides buttery-smooth momentum scrolling for the Landing page only.
|
||||
* Integrates seamlessly with GSAP ScrollTrigger animations.
|
||||
*
|
||||
* Usage: Wrap your app routes with <SmoothScroll>
|
||||
*/
|
||||
export default function SmoothScroll({ children }) {
|
||||
const location = useLocation();
|
||||
|
||||
// Only enable smooth scroll on Landing page
|
||||
const isLandingPage = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
// Skip initialization if not on Landing page
|
||||
if (!isLandingPage) return;
|
||||
|
||||
// Initialize Lenis with optimal settings
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2, // Scroll duration (higher = smoother but slower)
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // Easing function
|
||||
orientation: 'vertical', // Vertical scrolling
|
||||
gestureOrientation: 'vertical',
|
||||
smoothWheel: true, // Smooth mouse wheel scrolling
|
||||
wheelMultiplier: 1, // Mouse wheel sensitivity
|
||||
smoothTouch: false, // Disable on touch devices (better native feel)
|
||||
touchMultiplier: 2,
|
||||
infinite: false,
|
||||
});
|
||||
|
||||
// Integrate Lenis with GSAP ScrollTrigger
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
|
||||
// Add Lenis to GSAP ticker for smooth updates
|
||||
gsap.ticker.add((time) => {
|
||||
lenis.raf(time * 1000); // Convert GSAP time to milliseconds
|
||||
});
|
||||
|
||||
// Disable GSAP's lag smoothing to prevent conflicts
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// Cleanup on unmount or route change
|
||||
return () => {
|
||||
lenis.destroy();
|
||||
gsap.ticker.remove(lenis.raf);
|
||||
};
|
||||
}, [isLandingPage]); // Re-run when route changes
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
+53
-3
@@ -25,6 +25,7 @@ const Landing = () => {
|
||||
const heroRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const sectionsRef = useRef([]);
|
||||
const statRefs = useRef([]);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,6 +56,34 @@ const Landing = () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Animated Stat Counters
|
||||
statRefs.current.forEach((el) => {
|
||||
if (!el) return;
|
||||
|
||||
const target = parseFloat(el.dataset.target);
|
||||
const suffix = el.dataset.suffix || '';
|
||||
|
||||
gsap.fromTo(el,
|
||||
{ innerText: 0 },
|
||||
{
|
||||
innerText: target,
|
||||
duration: 2,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 85%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
snap: { innerText: suffix === '%' ? 1 : 0.1 },
|
||||
onUpdate: function () {
|
||||
const value = this.targets()[0].innerText;
|
||||
const formattedValue = suffix === '%' ? Math.ceil(value) : Math.ceil(value);
|
||||
el.innerText = formattedValue + suffix;
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
}, []);
|
||||
|
||||
const addToRefs = (el) => {
|
||||
@@ -325,17 +354,38 @@ const Landing = () => {
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
||||
<div className="text-4xl font-black text-red-500 mb-2">40%</div>
|
||||
<div
|
||||
ref={(el) => statRefs.current[0] = el}
|
||||
data-target="40"
|
||||
data-suffix="%"
|
||||
className="text-4xl font-black text-red-500 mb-2"
|
||||
>
|
||||
0%
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2">Insurance Denials</h3>
|
||||
<p className="text-zinc-400 text-sm">Of claims are denied due to "lack of maintenance" if proof of regular inspection isn't provided.</p>
|
||||
</div>
|
||||
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
||||
<div className="text-4xl font-black text-yellow-500 mb-2">5yrs</div>
|
||||
<div
|
||||
ref={(el) => statRefs.current[1] = el}
|
||||
data-target="5"
|
||||
data-suffix="yrs"
|
||||
className="text-4xl font-black text-yellow-500 mb-2"
|
||||
>
|
||||
0yrs
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2">Life Reduced</h3>
|
||||
<p className="text-zinc-400 text-sm">A minor leak left for 6 months can reduce your roof's lifespan by up to 5 years.</p>
|
||||
</div>
|
||||
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
||||
<div className="text-4xl font-black text-green-500 mb-2">15m</div>
|
||||
<div
|
||||
ref={(el) => statRefs.current[2] = el}
|
||||
data-target="15"
|
||||
data-suffix="m"
|
||||
className="text-4xl font-black text-green-500 mb-2"
|
||||
>
|
||||
0m
|
||||
</div>
|
||||
<h3 className="font-bold text-xl mb-2">Our Estimate Time</h3>
|
||||
<p className="text-zinc-400 text-sm">While others take days, we give you a quote before our drone even lands.</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user