diff --git a/next.config.ts b/next.config.ts index 4f95361..4c5003e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,7 +10,18 @@ const nextConfig: NextConfig = { // typedRoutes: true, allowedDevOrigins: ["tillisoftware.local"], images: { - remotePatterns: [new URL("https://placecats.com/**")], + remotePatterns: [ + { + protocol: "https", + hostname: "placecats.com", + pathname: "/**", + }, + { + protocol: "https", + hostname: "picsum.photos", + pathname: "/**", + }, + ], // dangerouslyAllowSVG: true, }, experimental: { diff --git a/package.json b/package.json index c1df37e..22d2772 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", - "embla-carousel-auto-scroll": "^8.6.0", - "embla-carousel-autoplay": "^8.6.0", - "embla-carousel-react": "^8.6.0", + "embla-carousel": "9.0.0-rc01", + "embla-carousel-auto-scroll": "9.0.0-rc01", + "embla-carousel-autoplay": "9.0.0-rc01", + "embla-carousel-class-names": "9.0.0-rc01", + "embla-carousel-react": "9.0.0-rc01", "gsap": "^3.13.0", "hast-util-to-jsx-runtime": "^2.3.6", "input-otp": "^1.4.2", @@ -80,4 +82,4 @@ "tw-animate-css": "^1.4.0", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5539b1b..f50a859 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,15 +110,21 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + embla-carousel: + specifier: 9.0.0-rc01 + version: 9.0.0-rc01 embla-carousel-auto-scroll: - specifier: ^8.6.0 - version: 8.6.0(embla-carousel@8.6.0) + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) embla-carousel-autoplay: - specifier: ^8.6.0 - version: 8.6.0(embla-carousel@8.6.0) + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) + embla-carousel-class-names: + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(embla-carousel@9.0.0-rc01) embla-carousel-react: - specifier: ^8.6.0 - version: 8.6.0(react@19.2.0) + specifier: 9.0.0-rc01 + version: 9.0.0-rc01(react@19.2.0) gsap: specifier: ^3.13.0 version: 3.13.0 @@ -1444,28 +1450,33 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - embla-carousel-auto-scroll@8.6.0: - resolution: {integrity: sha512-WT9fWhNXFpbQ6kP+aS07oF5IHYLZ1Dx4DkwgCY8Hv2ZyYd2KMCPfMV1q/cA3wFGuLO7GMgKiySLX90/pQkcOdQ==} + embla-carousel-auto-scroll@9.0.0-rc01: + resolution: {integrity: sha512-9lMYoiriEwy2foZ0i7lHG9rU8TMPxSgw3jXmZr4de/UXzqB/fv+z8deHIOORSlA/5Po+6r6+mov3g2tT3T0S4w==} peerDependencies: - embla-carousel: 8.6.0 + embla-carousel: 9.0.0-rc01 - embla-carousel-autoplay@8.6.0: - resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} + embla-carousel-autoplay@9.0.0-rc01: + resolution: {integrity: sha512-gl7jUe0X9xd5v7IiyFF2FCR+KTBesnutM0gQ3S75oo9EizZi5tJMNdVaFwvzepz6kGzDkkSbKMWitQvAbFVfdQ==} peerDependencies: - embla-carousel: 8.6.0 + embla-carousel: 9.0.0-rc01 - embla-carousel-react@8.6.0: - resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + embla-carousel-class-names@9.0.0-rc01: + resolution: {integrity: sha512-3REWZOQGpc5cpbusftwghFle8yvINlCIqIj0QlonWRGsrgh6W+m4bAYeF0gKY9iGbuKfCRxDTBqdWQGA/DDzpA==} + peerDependencies: + embla-carousel: 9.0.0-rc01 + + embla-carousel-react@9.0.0-rc01: + resolution: {integrity: sha512-2ik9QtVm3UXJWkVdEEm6bInmxNSmxq9Z2q5GWuJx3v2vZvujmlDzcrIE6bvh+wWgPmDn6jekJCRHm1eEl/N0SA==} peerDependencies: react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - embla-carousel-reactive-utils@8.6.0: - resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + embla-carousel-reactive-utils@9.0.0-rc01: + resolution: {integrity: sha512-RnW0NMrL7wVAQb9jro+l96hLI2JairyFHS2Jv+fvXakveD/c5aD9aoNH94YRbTmi0G7PxrKSxydmCpTy5eFmrA==} peerDependencies: - embla-carousel: 8.6.0 + embla-carousel: 9.0.0-rc01 - embla-carousel@8.6.0: - resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + embla-carousel@9.0.0-rc01: + resolution: {integrity: sha512-4BTERU1gAXgg4Vl0m7hQ1GzePGLNNfM2j030ww8i9idiPXumyRUpaNUDfT2zx1Hv8um1Ew7QKBy/HdNPz8L30g==} enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} @@ -3198,25 +3209,29 @@ snapshots: '@babel/runtime': 7.28.4 csstype: 3.1.3 - embla-carousel-auto-scroll@8.6.0(embla-carousel@8.6.0): + embla-carousel-auto-scroll@9.0.0-rc01(embla-carousel@9.0.0-rc01): + dependencies: + embla-carousel: 9.0.0-rc01 + + embla-carousel-autoplay@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: - embla-carousel: 8.6.0 + embla-carousel: 9.0.0-rc01 - embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): + embla-carousel-class-names@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: - embla-carousel: 8.6.0 + embla-carousel: 9.0.0-rc01 - embla-carousel-react@8.6.0(react@19.2.0): + embla-carousel-react@9.0.0-rc01(react@19.2.0): dependencies: - embla-carousel: 8.6.0 - embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + embla-carousel: 9.0.0-rc01 + embla-carousel-reactive-utils: 9.0.0-rc01(embla-carousel@9.0.0-rc01) react: 19.2.0 - embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + embla-carousel-reactive-utils@9.0.0-rc01(embla-carousel@9.0.0-rc01): dependencies: - embla-carousel: 8.6.0 + embla-carousel: 9.0.0-rc01 - embla-carousel@8.6.0: {} + embla-carousel@9.0.0-rc01: {} enhanced-resolve@5.18.3: dependencies: diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index f8007a3..f33de0c 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -303,7 +303,7 @@ export default function Home() {
- + /> */}
@@ -377,8 +377,8 @@ export default function Home() { - -
+ {/* TODO: make a set mx for mobile */} +
{ - const interval = setInterval(() => { - setActiveIndex((current) => (current + 1) % industries.length); - }, 5000); // Rotate every 5 seconds - - return () => clearInterval(interval); - }, []); - - const handleIndustryClick = (index: number) => { - setActiveIndex(index); - }; - - // Create an extended array for continuous scrolling effect - const getVisibleItems = () => { - const items = []; - // Show current and next 3 items (or more depending on viewport) - for (let i = 0; i < industries.length + 2; i++) { - const index = (activeIndex + i) % industries.length; - items.push({ ...industries[index], displayIndex: i }); - } - return items; - }; - - const visibleItems = getVisibleItems(); - - return ( - <> -
-

Solutions for

-
- {visibleItems.map((item, idx) => { - const isActive = item.displayIndex === 0; - const offset = item.displayIndex; - - // Calculate translateX based on size differences - let translateX = 0; - if (offset === 0) { - translateX = 0; - } else if (offset === 1) { - // Position after the active text + arrow + some gap - translateX = 550; // Adjust based on active text width - } else if (offset === 2) { - translateX = 900; - } else if (offset === 3) { - translateX = 1200; - } - - const opacity = offset === 0 ? 1 : offset <= 3 ? 1 : 0; - - return ( - - ); - })} -
-
- -

{industries[activeIndex].description}

- -
- {visibleItems.map((item, idx) => { - const isActive = item.displayIndex === 0; - const offset = item.displayIndex; - - // Position items: active at 0, others spaced to the right - const translateX = offset * 650; - const scale = isActive ? 1 : 0.6; - // Show active (1.0), next 2 items (0.3), third item fading (0.15) - const opacity = - offset === 0 - ? 1 - : offset === 1 - ? 0.3 - : offset === 2 - ? 0.3 - : offset === 3 - ? 0.15 - : 0; - const zIndex = isActive - ? 10 - : offset === 1 - ? 5 - : offset === 2 - ? 4 - : 1; - - return ( -
-
- {item.name} -
- {isActive && ( - - )} -
- ); - })} -
- - ); -} diff --git a/src/app/home/solutions-carousel/TextContentSection.tsx b/src/app/home/solutions-carousel/TextContentSection.tsx new file mode 100644 index 0000000..15515cf --- /dev/null +++ b/src/app/home/solutions-carousel/TextContentSection.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { ChevronRightIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type ActiveIndustry = { + name: string; + description: string; +}; + +export default function TextContentSection({ + activeIndustry, +}: { + activeIndustry: ActiveIndustry; +}) { + return ( + // dont want to hardcode pixels here. just want this text width to remain the same +
+
+

+ Solutions for +

+ + +
+ +

+ {activeIndustry.description} +

+
+ ); +} diff --git a/src/app/home/solutions-carousel/data.ts b/src/app/home/solutions-carousel/data.ts new file mode 100644 index 0000000..15b3834 --- /dev/null +++ b/src/app/home/solutions-carousel/data.ts @@ -0,0 +1,82 @@ +export const industries = [ + { + name: "Banking and Finance", + description: + "Transform your company's financial solutions. Improve AML and KYC compliance and reduce operating costs while increasing customer satisfaction.", + image: "https://picsum.photos/600/350?v=4", + }, + { + name: "Education and Universities", + description: + "Streamline tuition payments, campus services, and student billing with automated workflows designed for educational institutions.", + image: "https://picsum.photos/600/350?v=3", + }, + { + name: "Insurance", + description: + "Simplify premium collections, claims processing, and policy management with intelligent automation built for insurance providers.", + image: "https://picsum.photos/600/350?v=2", + }, + { + name: "Utilities", + description: + "Modernize utility billing, meter-to-cash workflows, and customer payments with real-time tracking and automated reminders.", + image: "https://picsum.photos/600/350?v=1", + }, + { + name: "Banking and Finance", + description: + "Transform your company's financial solutions. Improve AML and KYC compliance and reduce operating costs while increasing customer satisfaction.", + image: "https://picsum.photos/600/350?v=4", + }, + { + name: "Education and Universities", + description: + "Streamline tuition payments, campus services, and student billing with automated workflows designed for educational institutions.", + image: "https://picsum.photos/600/350?v=3", + }, + { + name: "Insurance", + description: + "Simplify premium collections, claims processing, and policy management with intelligent automation built for insurance providers.", + image: "https://picsum.photos/600/350?v=2", + }, + { + name: "Utilities", + description: + "Modernize utility billing, meter-to-cash workflows, and customer payments with real-time tracking and automated reminders.", + image: "https://picsum.photos/600/350?v=1", + }, +]; + +// TODO: delete above and uncomment this +// const industries = [ +// { +// name: "Banking and Finance", +// description: +// "Transform your company's financial solutions. Improve AML and KYC compliance and reduce operating costs while increasing customer satisfaction.", +// image: +// "https://www.figma.com/api/mcp/asset/d48da3a6-7db6-4d57-8d1c-8bd157cf220c", +// }, +// { +// name: "Education and Universities", +// description: +// "Streamline tuition payments, campus services, and student billing with automated workflows designed for educational institutions.", +// image: +// "https://www.figma.com/api/mcp/asset/f6cc94b0-8742-4fc3-8476-965bdae60183", +// }, +// { +// name: "Insurance", +// description: +// "Simplify premium collections, claims processing, and policy management with intelligent automation built for insurance providers.", +// image: +// "https://www.figma.com/api/mcp/asset/3204408a-bf6c-48d3-a62d-64e82699389f", +// }, +// { +// name: "Utilities", +// description: +// "Modernize utility billing, meter-to-cash workflows, and customer payments with real-time tracking and automated reminders.", +// image: +// "https://www.figma.com/api/mcp/asset/ae6da2ac-178b-49bb-881f-a6098a7710d9", +// }, +// ]; diff --git a/src/app/home/solutions-carousel/index.tsx b/src/app/home/solutions-carousel/index.tsx new file mode 100644 index 0000000..1a390c4 --- /dev/null +++ b/src/app/home/solutions-carousel/index.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { + EmblaCarouselType, + EmblaEventListType, + EmblaEventModelType, + EmblaOptionsType, +} from "embla-carousel"; +import Autoplay from "embla-carousel-autoplay"; +import ClassNames from "embla-carousel-class-names"; +import useEmblaCarousel from "embla-carousel-react"; +import Image from "next/image"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { industries } from "./data"; +import TextContentSection from "./TextContentSection"; +import { useUnidirectionalEmbla } from "./useUnidirectionalEmbla"; + +const TWEEN_FACTOR_BASE = 0.4; + +const clamp = (number: number, min: number, max: number): number => + Math.min(Math.max(number, min), max); + +export function SolutionsCarousel() { + const [selectedIdx, setSelectedIdx] = useState(0); + + const options: Partial = { + align: "start", + loop: true, // for some reason this is buggy, but we need this + containScroll: "trimSnaps", + skipSnaps: true, + }; + + const plugins = [ + Autoplay(), + ClassNames({ snapped: "is-snapped", active: true }), + ]; + const [emblaRef, emblaApi] = useEmblaCarousel(options, plugins); + + useUnidirectionalEmbla(emblaApi); + + // // sync carousel changes with selected idx + useEffect(() => { + if (!emblaApi) return; + + const onSelect = () => { + setSelectedIdx(emblaApi.selectedSnap()); + }; + + emblaApi.on("select", onSelect); + onSelect(); // set initial idx + + return () => { + emblaApi.off("select", onSelect); + }; + }, [emblaApi]); + + const tweenFactor = useRef(0); + const tweenNodes = useRef([]); + + const setTweenNodes = useCallback((emblaApi: EmblaCarouselType): void => { + tweenNodes.current = emblaApi.slideNodes().map((slideNode) => { + return slideNode.querySelector(".embla__slide__img") as HTMLElement; + }); + console.log(tweenNodes.current); + }, []); + + const setTweenFactor = useCallback((emblaApi: EmblaCarouselType) => { + tweenFactor.current = TWEEN_FACTOR_BASE * emblaApi.snapList().length; + }, []); + + const tweenScale = useCallback( + ( + emblaApi: EmblaCarouselType, + event?: EmblaEventModelType, + ) => { + const engine = emblaApi.internalEngine(); + const scrollProgress = emblaApi.scrollProgress(); + const slidesInView = emblaApi.slidesInView(); + const isScrollEvent = event?.type === "scroll"; + + emblaApi.snapList().forEach((scrollSnap, snapIndex) => { + let diffToTarget = scrollSnap - scrollProgress; + const slidesInSnap = engine.scrollSnapList.slidesBySnap[snapIndex]; + + slidesInSnap.forEach((slideIndex) => { + if (isScrollEvent && !slidesInView.includes(slideIndex)) return; + + if (engine.options.loop) { + engine.slideLooper.loopPoints.forEach((loopItem) => { + const target = loopItem.target(); + + if (slideIndex === loopItem.index && target !== 0) { + const sign = Math.sign(target); + + if (sign === -1) { + diffToTarget = scrollSnap - (1 + scrollProgress); + } + if (sign === 1) { + diffToTarget = scrollSnap + (1 - scrollProgress); + } + } + }); + } + + const tweenValue = 1 - Math.abs(diffToTarget * tweenFactor.current); + const scale = clamp(tweenValue, 0, 1).toString(); + const tweenNode = tweenNodes.current[slideIndex]; + if (!tweenNode) { + console.log("missing tween node for slide index", slideIndex); + return; + } + tweenNode.style.transform = `scale(${scale})`; + }); + }); + }, + [], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (!emblaApi) return; + + setTweenNodes(emblaApi); + setTweenFactor(emblaApi); + tweenScale(emblaApi); + + emblaApi + .on("reinit", setTweenNodes) + .on("reinit", setTweenFactor) + .on("reinit", tweenScale) + .on("scroll", tweenScale) + .on("slidefocus", tweenScale); + }, [emblaApi]); + + const currentIndustry = industries[selectedIdx % industries.length]; + + return ( +
+ + + {/* Carousel */} +
+
+
+ {industries.map((industry, idx) => ( +
+ {industry.name} +

{industry.name}

+
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/home/solutions-carousel/useUnidirectionalEmbla.ts b/src/app/home/solutions-carousel/useUnidirectionalEmbla.ts new file mode 100644 index 0000000..636a100 --- /dev/null +++ b/src/app/home/solutions-carousel/useUnidirectionalEmbla.ts @@ -0,0 +1,55 @@ +import type { EmblaCarouselType } from "embla-carousel"; +import { useEffect, useRef } from "react"; + +export function useUnidirectionalEmbla( + emblaApi: EmblaCarouselType | undefined, +) { + const startXRef = useRef(null); + const startIndexRef = useRef(0); + const isBlockingRef = useRef(false); + + useEffect(() => { + if (!emblaApi) return; + + const viewport = emblaApi.rootNode(); + + const onPointerDown = (e: PointerEvent) => { + startXRef.current = e.clientX; + startIndexRef.current = emblaApi.selectedSnap(); + isBlockingRef.current = false; + }; + + const onPointerMove = (e: PointerEvent) => { + if (startXRef.current === null) return; + + const deltaX = e.clientX - startXRef.current; + + // block backward scrolling + if (deltaX > 0) { + isBlockingRef.current = true; + + e.preventDefault(); + e.stopImmediatePropagation(); + + // scroll to the start index + // emblaApi.scrollTo(startIndexRef.current, false); + } + }; + + const onPointerUp = () => { + startXRef.current = null; + isBlockingRef.current = false; + }; + + viewport.addEventListener("pointerdown", onPointerDown, { passive: false }); + viewport.addEventListener("pointermove", onPointerMove, { passive: false }); + viewport.addEventListener("pointerup", onPointerUp); + viewport.addEventListener("pointercancel", onPointerUp); + return () => { + viewport.removeEventListener("pointerdown", onPointerDown); + viewport.removeEventListener("pointermove", onPointerMove); + viewport.removeEventListener("pointerup", onPointerUp); + viewport.removeEventListener("pointercancel", onPointerUp); + }; + }, [emblaApi]); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 186a7f3..643d198 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ import Home from "./home/page"; +import "../css/embla.css"; export default Home; diff --git a/src/css/embla.css b/src/css/embla.css new file mode 100644 index 0000000..7f1a180 --- /dev/null +++ b/src/css/embla.css @@ -0,0 +1,72 @@ +.embla { + min-height: 25rem; + --slide-height: 12rem; + --slide-spacing: 1rem; + --slide-size: 33%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + /* background-color: red; */ +} + +@media (min-width: 768px) { + .embla { + max-width: 100rem; + min-height: 30rem; + --slide-height: 16rem; + --slide-spacing: 2rem; + --slide-size: 50%; + } +} + +@media (min-width: 1024px) { + .embla { + max-width: 100rem; + min-height: 40rem; + --slide-height: 19rem; + --slide-spacing: 3rem; + --slide-size: 30%; + } +} + +.embla__viewport { + overflow: hidden; + width: 100%; +} + +.embla__container { + display: flex; + margin-left: calc(var(--slide-spacing) * -1); + transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); + touch-action: pan-y pinch-zoom; +} + +.embla__slide { + flex: 0 0 var(--slide-size); + padding-left: var(--slide-spacing); +} + +.embla__slide.is-snapped { + /* transform: translateY(-48px) !important; */ + /* --slide-height: 25rem; */ + /* --slide-size: 40%; */ +} + +.embla__slide:not(.is-snapped) { + opacity: 0.16; +} + +.embla__slide__img { + height: var(--slide-height); + width: 100%; + object-fit: cover; + border-radius: 1.35rem; + user-select: none; +} + + + +.animate-fade-slide { + animation: fadeSlide 300ms ease; +}