diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index f6a471542..56d47c3cd 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -1,4 +1,5 @@ import { useTranslation } from "next-i18next"; +import classNames from "classnames"; import styles from "./Footer.module.scss"; import LanguageSelector from "../LanguageSelector"; import Divider from "../shared/Divider"; @@ -23,11 +24,11 @@ const CopyrightRow = () => { ); }; -const Footer = () => { +const Footer = ({ className }) => { const { t } = useTranslation(); return ( -
+
diff --git a/src/components/icons/Caret.module.scss b/src/components/icons/Caret.module.scss new file mode 100644 index 000000000..8dcbbc5d2 --- /dev/null +++ b/src/components/icons/Caret.module.scss @@ -0,0 +1,14 @@ +.CaretIcon { + transition: transform 0.25s ease-in-out; + + // Direction + &[data-direction="down"] { + transform: rotate(270deg); + } + &[data-direction="left"] { + transform: rotate(180deg); + } + &[data-direction="up"] { + transform: rotate(90deg); + } +} diff --git a/src/components/icons/Caret.tsx b/src/components/icons/Caret.tsx new file mode 100644 index 000000000..5c1e1bb1a --- /dev/null +++ b/src/components/icons/Caret.tsx @@ -0,0 +1,52 @@ +import { FC, SVGProps } from "react"; +import classNames from "classnames"; + +import styles from "./Caret.module.scss"; + +interface CaretIconProps extends SVGProps { + color?: string; + direction?: "up" | "down" | "left" | "right"; + className?: string; +} + +const CaretIcon: FC = ({ + color = "white", + direction = "right", + className, + ...props +}) => { + return ( + + + + + + + + + + + ); +}; + +export default CaretIcon; diff --git a/src/components/shared/CardsSlider.module.scss b/src/components/shared/CardsSlider.module.scss new file mode 100644 index 000000000..8818515ab --- /dev/null +++ b/src/components/shared/CardsSlider.module.scss @@ -0,0 +1,132 @@ +@import "~@/scss/solutions/_variables.scss"; + +.Title { + font-size: 40px; + font-weight: 700; + line-height: 1.04; + letter-spacing: -0.01em; + text-align: center; + color: var(--white); + text-transform: capitalize; + margin: 0; + padding: 64px 24px 0; + + strong { + background: var(--gradient-2); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + } + + @include breakpoint(md) { + font-size: 56px; + padding-top: 80px; + } + + @include breakpoint(lg) { + font-size: 64px; + padding-top: 128px; + } +} + +.Carousel { + position: relative; + width: 100%; +} + +.CarouselContainer { + display: flex; + width: 100%; + padding: { + top: 64px; + bottom: 64px; + } + + overflow-x: scroll; + overscroll-behavior-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; /*FireFox*/ + -ms-overflow-style: -ms-autohiding-scrollbar; /*IE10+*/ + + /*Chrome, Safari, Edge*/ + &::-webkit-scrollbar { + display: none; + } + + @include breakpoint(md) { + padding: { + top: 80px; + bottom: 40px; + } + } + + @include breakpoint(lg) { + padding: { + top: 96px; + } + } +} + +.Cards { + display: flex; + justify-content: start; + gap: 16px; + padding-left: 16px; + max-width: 80rem; + margin: 0 auto; +} + +.CardWrapper { + border-radius: 1.5rem; + box-sizing: content-box; + + &:last-child { + padding-right: 3%; + + @include breakpoint(md) { + padding-right: 10%; + } + + @include breakpoint(lg) { + padding-right: 33%; + } + } + + @include breakpoint(md) { + > * { + width: 22rem; + height: 100%; + } + } +} + +.Arrows { + display: flex; + justify-content: center; + gap: 16px; + padding-bottom: 64px; + + button { + position: relative; + z-index: 40; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: var(--grey-300); + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.1s ease; + transition: + background 0.3s ease-in-out, + transform 0.15s $easeInOutQuart; + + &:disabled { + background-color: var(--grey-500); + + svg path { + stroke: var(--grey-300); + } + } + } +} diff --git a/src/components/shared/CardsSlider.tsx b/src/components/shared/CardsSlider.tsx new file mode 100644 index 000000000..f76474ab7 --- /dev/null +++ b/src/components/shared/CardsSlider.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState, useRef, ReactNode } from "react"; +import { motion } from "framer-motion"; +import { Trans } from "next-i18next"; +import { useInView } from "react-intersection-observer"; +import classNames from "classnames"; + +import CaretIcon from "@/components/icons/Caret"; +import { AnimatedText } from "@/components/shared/Text"; + +import styles from "./CardsSlider.module.scss"; + +interface CarouselProps { + items: Element[] | ReactNode[]; + titleKey?: string; + initialScroll?: number; + id?: string; + className?: string; + carouselClassName?: string; + cardsClassName?: string; + cardWrapperClassName?: string; +} + +const CardsSlider = ({ + items, + titleKey, + initialScroll = 0, + id = "", + className = "", + carouselClassName = "", + cardsClassName = "", + cardWrapperClassName = "", +}: CarouselProps) => { + const carouselRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + useEffect(() => { + if (carouselRef.current) { + carouselRef.current.scrollLeft = initialScroll; + checkScrollability(); + } + }, [initialScroll]); + + const checkScrollability = () => { + if (carouselRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth); + } + }; + + const scrollLeft = () => { + if (carouselRef.current) { + carouselRef.current.scrollBy({ left: -304, behavior: "smooth" }); + } + }; + + const scrollRight = () => { + if (carouselRef.current) { + carouselRef.current.scrollBy({ left: 304, behavior: "smooth" }); + } + }; + + const { ref, inView } = useInView({ + triggerOnce: true, + threshold: 0.5, + }); + + return ( +
+ {titleKey && ( + + + + )} + +
+
+
+ {items.map((item: any, index: number) => ( + + {item} + + ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default CardsSlider; diff --git a/src/components/shared/CollapsibleContent.module.scss b/src/components/shared/CollapsibleContent.module.scss new file mode 100644 index 000000000..81ccf47d2 --- /dev/null +++ b/src/components/shared/CollapsibleContent.module.scss @@ -0,0 +1,30 @@ +@import "../../scss/solutions/_variables.scss"; + +.CollapsibleTrigger { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + gap: 16px; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.16; + color: var(--grey-200); + text-align: left; + margin-bottom: 0; + + p { + margin: 0; + } +} + +.IconArrowDown { + transform: rotate(180deg); +} + +@include breakpoint(md) { + .CollapsibleTrigger { + justify-content: flex-start; + } +} diff --git a/src/components/shared/CollapsibleContent.tsx b/src/components/shared/CollapsibleContent.tsx new file mode 100644 index 000000000..d1070f0bc --- /dev/null +++ b/src/components/shared/CollapsibleContent.tsx @@ -0,0 +1,84 @@ +import { useState, ReactNode } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import styles from "./CollapsibleContent.module.scss"; +import CaretIcon from "@/components/icons/Caret"; + +interface CollapsibleContentProps { + label: string; + defaultOpen?: boolean; + className?: string; + triggerClassName?: string; + onOpenChange?: () => void; + previewContent?: ReactNode; + children: ReactNode; +} + +const CollapsibleContent: React.FC = ({ + label, + defaultOpen, + className, + triggerClassName, + onOpenChange, + previewContent, + children, +}) => { + const [open, setOpen] = useState(defaultOpen || false); + + const handleOpenChange = () => { + setOpen(!open); + onOpenChange && onOpenChange(); + }; + + return ( + + + + + + {previewContent ? previewContent : null} + + + {open && ( + + {children} + + )} + + + ); +}; + +export default CollapsibleContent; diff --git a/src/components/shared/LottieCarousel.module.scss b/src/components/shared/LottieCarousel.module.scss new file mode 100644 index 000000000..b021b279d --- /dev/null +++ b/src/components/shared/LottieCarousel.module.scss @@ -0,0 +1,95 @@ +@import "~@/scss/solutions/_variables.scss"; + +.LottieCarouselSection { + padding: 64px 0 0; + + @include breakpoint(lg) { + padding-top: 7rem; + } +} + +.LottieCarouselItem { + transition: opacity 0.3s ease; + + @include breakpoint(lg) { + opacity: 0.5; + } + + &.ActiveSlide { + opacity: 1; + transition: opacity 0.3s ease; + } + + > * { + padding-left: 24px; + padding-right: 24px; + } + + p { + font-weight: 700; + font-size: 16px; + line-height: 1.16; + text-align: center; + letter-spacing: -0.01em; + color: var(--grey-250); + margin: 40px auto 0; + + @include breakpoint(md) { + font-size: 18px; + } + @include breakpoint(lg) { + max-width: 100%; + } + + strong { + color: var(--white); + } + } + + .LottieWrapper { + margin: 0 auto; + padding: 0 3rem; + max-width: 70vw; + + @include breakpoint(md) { + max-width: 400px; + } + } +} + +.Arrows { + display: flex; + justify-content: center; + gap: 16px; + padding-bottom: 40px; + padding-top: 32px; + + @include breakpoint(lg) { + padding-bottom: 64px; + padding-top: 40px; + } + + button { + position: relative; + z-index: 40; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: var(--grey-300); + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.1s ease; + transition: + background 0.3s ease-in-out, + transform 0.15s $easeInOutQuart; + + &:disabled { + background-color: var(--grey-500); + + svg path { + stroke: var(--grey-300); + } + } + } +} diff --git a/src/components/shared/LottieCarousel.tsx b/src/components/shared/LottieCarousel.tsx new file mode 100644 index 000000000..aa6ce83db --- /dev/null +++ b/src/components/shared/LottieCarousel.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useRef, ReactNode, useEffect, useState } from "react"; +import Lottie from "react-lottie"; +import Slider from "react-slick"; +import "slick-carousel/slick/slick.css"; +import "slick-carousel/slick/slick-theme.css"; + +import Text from "@/components/shared/Text"; +import CaretIcon from "@/components/icons/Caret"; + +import styles from "./LottieCarousel.module.scss"; + +interface LottieCarouselProps { + itemsMobile: ReactNode[]; + itemsDesktop?: ReactNode[]; + itemsStateMobile?: any[]; + itemsStateDesktop?: any[]; + setItemsStateMobile?: (_state: any) => void; + setItemsStateDesktop?: (_state: any) => void; +} + +const LottieCarousel = ({ + itemsMobile, + itemsDesktop, + itemsStateMobile, + itemsStateDesktop, + setItemsStateMobile, + setItemsStateDesktop, +}: LottieCarouselProps) => { + const sliderMobileRef = useRef(null); + const sliderDesktopRef = useRef(null); + + const [isMobile, setIsMobile] = useState(null); + + useEffect(() => { + if (window.innerWidth < 992) { + setIsMobile(true); + } else { + setIsMobile(false); + } + }, []); + + const onBeforeChange = (current: number, next: number) => { + if (isMobile) { + let stateMobileCopy = new Array(itemsStateMobile.length).fill(true); + stateMobileCopy[next] = false; + setItemsStateMobile(stateMobileCopy); + } + }; + + const onAfterChange = () => { + if (!isMobile) { + const slick = document.querySelector( + `.${styles.LottieCarouselSection} .d-none.d-md-block .slick-list`, + ); + const currentSlide = + sliderDesktopRef.current.innerSlider.state.currentSlide; + const stateDesktopCopy = new Array(itemsStateDesktop.length).fill(true); + stateDesktopCopy[currentSlide === 2 ? 0 : currentSlide + 1] = false; + setItemsStateDesktop(stateDesktopCopy); + + const slides = slick.querySelectorAll(".slick-slide"); + const slidesActive = slick.querySelectorAll(".slick-slide.slick-active"); + + for (let i = 0; i < slides.length; i++) { + const carouselItem = slides[i].querySelector( + `.${styles.LottieCarouselItem}`, + ); + carouselItem.classList.remove(`${styles.ActiveSlide}`); + } + + for (let i = 0; i < slidesActive.length; i++) { + const carouselItem = slidesActive[i].querySelector( + `.${styles.LottieCarouselItem}`, + ); + if (i === slidesActive.length - 1) { + carouselItem.classList.add(`${styles.ActiveSlide}`); + } else { + carouselItem.classList.remove(`${styles.ActiveSlide}`); + } + } + } + }; + + const handlePrev = () => { + sliderMobileRef.current.slickPrev(); + sliderDesktopRef.current.slickPrev(); + }; + + const handleNext = () => { + sliderMobileRef.current.slickNext(); + sliderDesktopRef.current.slickNext(); + }; + + const settings = { + // Show 3 slides at a time + dots: false, + autoplay: false, + arrows: false, + infinite: true, + beforeChange: onBeforeChange, + afterChange: onAfterChange, + slidesToShow: 3, + slidesToScroll: 1, + centerMode: true, + centerPadding: "0", + responsive: [ + { + // On mobile, show 1 slide at a time + breakpoint: 992, + settings: { + slidesToShow: 1, + centerMode: false, + }, + }, + ], + }; + + useEffect(() => { + if (!isMobile) { + const slick = document.querySelector( + `.${styles.LottieCarouselSection} .d-none.d-md-block .slick-list`, + ); + + const slidesActive = slick.querySelectorAll(".slick-slide.slick-active"); + + for (let i = 0; i < slidesActive.length; i++) { + const carouselItem = slidesActive[i].querySelector( + `.${styles.LottieCarouselItem}`, + ); + if (i === slidesActive.length - 1) { + carouselItem.classList.add(`${styles.ActiveSlide}`); + } else { + carouselItem.classList.remove(`${styles.ActiveSlide}`); + } + } + } + }, [isMobile]); + + return ( +
+
+ + {itemsMobile.map((item, index) => ( +
{item}
+ ))} +
+
+ +
+ + {itemsDesktop.map((item, index) => ( +
{item}
+ ))} +
+
+ +
+ + +
+
+ ); +}; + +interface LottieCarouselItemProps { + lottie: any; + text: string | ReactNode; + isLottiePaused: boolean; +} + +export const LottieCarouselItem = ({ + lottie, + text, + isLottiePaused, +}: LottieCarouselItemProps) => { + return ( +
+
+ +
+ + {text} + +
+ ); +}; + +export default LottieCarousel; diff --git a/src/components/shared/Motions.jsx b/src/components/shared/Motions.jsx new file mode 100644 index 000000000..0d68d61ef --- /dev/null +++ b/src/components/shared/Motions.jsx @@ -0,0 +1,102 @@ +import { motion } from "framer-motion"; +import { useInView } from "react-intersection-observer"; + +import { animations, easeFunctions, durations } from "@/constants/animations"; + +// +// Base motion wrapper component +// +export const MotionComponent = ({ + animateTrigger = "whenInView", // "variable" + startAnimation = true, + inViewProps = {}, + initial = {}, + animate = {}, + transition = {}, + delayIndex = 0, // automated way to delay by an X amount + delayBase = 0.4, + delayIncrease = 0.175, + element = "div", + children, + ...props +}) => { + // + // Enables triggering animation when component is in view + // + const { ref, inView } = useInView({ + triggerOnce: true, + threshold: 0.5, + ...inViewProps, + }); + + if (animateTrigger === "whenInView") { + startAnimation = inView; + } + + if (delayIndex) { + transition = { + ...transition, + delay: delayBase + delayIncrease * delayIndex, + }; + } + + const MotionComponentElement = motion[element]; + return ( + + {children} + + ); +}; + +// +// Animations +// + +// +// SlideIn +// +const slideInTransition = { + duration: durations.slower, + ease: easeFunctions.easeInQuart, +}; + +const slideInAnimations = { + bottom: { + initial: animations.fadeScaleSlideOut, + animate: animations.fadeScaleSlideIn, + }, + right: { + initial: animations.fadeScaleSlideOutFromRight, + animate: animations.fadeScaleSlideInFromRight, + }, + left: { + initial: animations.fadeScaleSlideOutFromLeft, + animate: animations.fadeScaleSlideInFromLeft, + }, +}; + +export const MotionSlideIn = ({ + transition = {}, + from = "bottom", + children, + ...props +}) => { + const animations = slideInAnimations[from]; + + return ( + + {children} + + ); +}; diff --git a/src/components/shared/Text.module.scss b/src/components/shared/Text.module.scss new file mode 100644 index 000000000..bca25cc0e --- /dev/null +++ b/src/components/shared/Text.module.scss @@ -0,0 +1,42 @@ +@import "~@/scss/solutions/_variables.scss"; + +.heading { + text-wrap: balance; +} + +.paragraph { + text-wrap: pretty; + + @include breakpoint(sm) { + max-width: 65%; + margin-left: auto; + margin-right: auto; + } +} + +.GradientText { + background: var(--gradient); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + + &::selection { + -webkit-text-fill-color: var(--black); + } +} + +.OpacityInText { + --delay: 0s; + + opacity: 0; + transition: + opacity $duration-standard $easeInQuart, + transform $duration-shorter $easeInQuart; + transform: translateY($anim-translate-y); + transition-delay: var(--delay); + + &.Active { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/shared/Text.tsx b/src/components/shared/Text.tsx new file mode 100644 index 000000000..c522cb404 --- /dev/null +++ b/src/components/shared/Text.tsx @@ -0,0 +1,156 @@ +import { + FC, + CSSProperties, + ReactNode, + useEffect, + useState, + useRef, +} from "react"; +import classNames from "classnames"; +import { MotionSlideIn } from "@/components/shared/Motions"; + +import styles from "./Text.module.scss"; + +interface TextProps { + element: keyof JSX.IntrinsicElements; + as?: "heading" | "paragraph"; + className?: string; + style?: CSSProperties; + children?: ReactNode; + + /** + * The `as` prop determines the styling of the text: + * - "heading" applies text-wrap balanced + * - "paragraph" applies text-wrap pretty + */ +} + +interface AnimatedTextProps extends TextProps { + delayIndex?: number; +} + +/** + * A simple text component that can be styled as a heading or paragraph. + */ +const Text: FC = ({ + element: Element, + as, + className, + style = {}, + children, +}) => ( + + {children} + +); + +/** + * A text component that animates when it comes into the viewport. + * + * @param element - The HTML element to render. + * @param as - Determines the styling of the text ("heading" or "paragraph"). + * @param className - Optional additional class names to apply. + * @param delayIndex - Optional delay index for the animation. + * @param children - The content to be animated. + */ +export const AnimatedText: FC = ({ + element, + as, + className, + delayIndex = 0, + children, +}) => ( + + {children} + +); + +/** + * Wrapper for gradient text. + * The children component should contain a element, + * which is what will be styled with the gradient. + * + * The `gradient` prop should be a linear gradient string. + * + * Example usage: + * + * + * Your Text Here + * + * + * Note: If it doesn't work with the gradient prop + * (maybe b/c of dynamically importing the component), + * set the --gradient CSS variable for the element in the + * component CSS file. + */ +export const GradientText: FC<{ + gradient?: string; + fallbackColor?: string; + children?: ReactNode; +}> = ({ gradient, fallbackColor = "white", children }) => { + const container = useRef(null); + + useEffect(() => { + if (gradient && container.current) { + container.current.style.setProperty("--gradient", gradient); + } + }, [container, gradient]); + + return ( + + {children} + + ); +}; + +/** + * A vanilla CSS text component that fades in when it comes into the viewport. + * Better performance than using a motion component. Use for hero text. + */ +export const OpacityInText: FC = ({ + element, + as, + className, + children, + delayIndex = 0, +}) => { + const [active, setActive] = useState(false); + + useEffect(() => { + setTimeout(() => setActive(true), 100); + }, []); + + return ( + + {children} + + ); +}; + +export default Text;