diff --git a/package.json b/package.json index eea73cc..933df6b 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "autoprefixer": "^10.4.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.34.0", "groq-sdk": "^0.37.0", "gsap": "^3.14.2", "leaflet": "^1.9.4", + "lenis": "^1.3.17", "lucide-react": "^0.563.0", "postcss": "^8.5.6", "react": "^19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d43201f..bd4e224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + framer-motion: + specifier: ^12.34.0 + version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) groq-sdk: specifier: ^0.37.0 version: 0.37.0 @@ -29,6 +32,9 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + lenis: + specifier: ^1.3.17 + version: 1.3.17(react@19.2.4) lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -1089,6 +1095,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.34.0: + resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1269,6 +1289,20 @@ packages: leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + lenis@1.3.17: + resolution: {integrity: sha512-k9T9rgcxne49ggJOvXCraWn5dt7u2mO+BNkhyu6yxuEnm9c092kAW5Bus5SO211zUvx7aCCEtzy9UWr0RB+oJw==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + react: '>=17.0.0' + vue: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + react: + optional: true + vue: + optional: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1408,6 +1442,12 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + motion-dom@12.34.0: + resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1767,6 +1807,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2801,6 +2844,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.34.0 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.3: optional: true @@ -2974,6 +3026,10 @@ snapshots: leaflet@1.9.4: {} + lenis@1.3.17(react@19.2.4): + optionalDependencies: + react: 19.2.4 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3240,6 +3296,12 @@ snapshots: dependencies: brace-expansion: 1.1.12 + motion-dom@12.34.0: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + ms@2.1.3: {} mz@2.7.0: @@ -3624,6 +3686,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/App.jsx b/src/App.jsx index a9a0d7e..dfc82ce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ( - - }> - {/* Public Routes */} - } /> - } /> + + + }> + {/* Public Routes */} + } /> + } /> - {/* Protected Field Agent Routes */} - - - - } /> + {/* Protected Field Agent Routes */} + + + + } /> - - - - } - /> - - - - } - /> + + + + } + /> + + + + } + /> - {/* Protected Admin Routes */} - - - - } - /> - - - - - } - /> - + {/* Protected Admin Routes */} + + + + } + /> - {/* Catch all */} - } /> - + + + + } + /> + + + {/* Catch all */} + } /> + + ); } diff --git a/src/components/SmoothScroll.jsx b/src/components/SmoothScroll.jsx new file mode 100644 index 0000000..bd23b22 --- /dev/null +++ b/src/components/SmoothScroll.jsx @@ -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 + */ +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}; +} diff --git a/src/pages/Landing.jsx b/src/pages/Landing.jsx index 40cd9c8..7c69caa 100644 --- a/src/pages/Landing.jsx +++ b/src/pages/Landing.jsx @@ -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 = () => {
-
40%
+
statRefs.current[0] = el} + data-target="40" + data-suffix="%" + className="text-4xl font-black text-red-500 mb-2" + > + 0% +

Insurance Denials

Of claims are denied due to "lack of maintenance" if proof of regular inspection isn't provided.

-
5yrs
+
statRefs.current[1] = el} + data-target="5" + data-suffix="yrs" + className="text-4xl font-black text-yellow-500 mb-2" + > + 0yrs +

Life Reduced

A minor leak left for 6 months can reduce your roof's lifespan by up to 5 years.

-
15m
+
statRefs.current[2] = el} + data-target="15" + data-suffix="m" + className="text-4xl font-black text-green-500 mb-2" + > + 0m +

Our Estimate Time

While others take days, we give you a quote before our drone even lands.