diff --git a/apps/common/CarouselControls.tsx b/apps/common/CarouselControls.tsx new file mode 100644 index 000000000..03785f6ba --- /dev/null +++ b/apps/common/CarouselControls.tsx @@ -0,0 +1,44 @@ +import {type ReactElement} from 'react'; +import {cl} from '@builtbymom/web3/utils'; + +type TCarouselControlsProps = { + carouselLength?: number; + onDotsClick: (destination: number) => void; + currentPage: number; +}; + +export function CarouselControls({ + carouselLength = 0, + onDotsClick, + currentPage +}: TCarouselControlsProps): ReactElement | null { + const numberOfControls = Math.ceil(carouselLength / 4); + + if (carouselLength && carouselLength < 5) { + return null; + } + + return ( +
+
+ {Array(numberOfControls) + .fill('') + .map((_, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/common/CarouselSlideArrows.tsx b/apps/common/CarouselSlideArrows.tsx new file mode 100644 index 000000000..1930f53b3 --- /dev/null +++ b/apps/common/CarouselSlideArrows.tsx @@ -0,0 +1,39 @@ +import {cl} from '@builtbymom/web3/utils'; + +import {IconChevron} from './icons/IconChevron'; + +import type {ReactElement} from 'react'; + +type TCarouselSlideArrowsProps = { + onScrollBack?: VoidFunction; + onScrollForward?: VoidFunction; + className?: string; +}; + +export function CarouselSlideArrows({ + onScrollBack, + onScrollForward, + className +}: TCarouselSlideArrowsProps): ReactElement { + return ( +
+
+
+ + +
+
+ ); +} diff --git a/apps/common/components/AppCard.tsx b/apps/common/components/AppCard.tsx new file mode 100644 index 000000000..643931886 --- /dev/null +++ b/apps/common/components/AppCard.tsx @@ -0,0 +1,72 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import {IconShare} from '@common/icons/IconShare'; + +import type {ReactElement} from 'react'; +import type {TApp} from '@common/types/category'; + +type TAppCardProps = { + app: TApp; +}; + +export function AppCard(props: TAppCardProps): ReactElement { + return ( + <> + +
+ + {props.app.logoURI ? ( + {props.app.name} + ) : ( +
+ )} +
+
{props.app.name}
+ +

{props.app.description}

+ + +
+ {props.app.logoURI ? ( +
+ {props.app.name} +
+ ) : ( +
+ )} +
+ +
+
{props.app.name}
+

{props.app.description}

+
+ + + ); +} diff --git a/apps/common/components/AppsCarousel.tsx b/apps/common/components/AppsCarousel.tsx new file mode 100644 index 000000000..56aef861f --- /dev/null +++ b/apps/common/components/AppsCarousel.tsx @@ -0,0 +1,81 @@ +import {type ForwardedRef, forwardRef, type ReactElement} from 'react'; +import React from 'react'; +import {cl} from '@builtbymom/web3/utils'; + +import {AppCard} from './AppCard'; +import {FeaturedApp} from './FeaturedApp'; + +import type {TApp} from '@common/types/category'; + +export const AppsCarousel = forwardRef( + ( + props: {onScroll?: VoidFunction; isUsingFeatured?: boolean; apps: TApp[]}, + ref: ForwardedRef + ): ReactElement => { + return ( +
+
+
+
+
+ {props.apps?.map((app, i) => { + return ( + + {props.isUsingFeatured ? ( + + ) : ( + + )} + + ); + })} +
+
+ {props.apps?.slice(0, 4).map((app, i) => { + return ( + + {props.isUsingFeatured ? ( + + ) : ( + + )} + + ); + })} +
+
+
+ ); + } +); diff --git a/apps/common/components/CategorySection.tsx b/apps/common/components/CategorySection.tsx new file mode 100644 index 000000000..8ca1a9548 --- /dev/null +++ b/apps/common/components/CategorySection.tsx @@ -0,0 +1,131 @@ +import {type ReactElement, useRef, useState} from 'react'; +import {useMountEffect} from '@react-hookz/web'; +import {CarouselControls} from '@common/CarouselControls'; +import {CarouselSlideArrows} from '@common/CarouselSlideArrows'; +import {IconShare} from '@common/icons/IconShare'; + +import {AppsCarousel} from './AppsCarousel'; + +import type {TApp} from '@common/types/category'; + +type TAppSectionProps = { + title: string; + onExpandClick: () => void; + apps: TApp[]; +}; + +export const CategorySection = ({title, onExpandClick, apps}: TAppSectionProps): ReactElement => { + const [shuffledApps, set_shuffledApps] = useState([]); + const [currentPage, set_currentPage] = useState(1); + const carouselRef = useRef(null); + const [isProgrammaticScroll, set_isProgrammaticScroll] = useState(false); + + /********************************************************************************************** + ** Handles scrolling back to the previous page in the carousel. + ** It updates the scroll position, current page, and sets a flag to indicate programmatic + ** scrolling. The flag is reset after a delay to allow for smooth scrolling. + *********************************************************************************************/ + const onScrollBack = (): void => { + if (!carouselRef.current || currentPage === 1) return; + set_isProgrammaticScroll(true); + carouselRef.current.scrollLeft -= 880; + set_currentPage(prev => prev - 1); + + setTimeout(() => { + set_isProgrammaticScroll(false); + }, 3000); + }; + + /********************************************************************************************** + ** Handles scrolling forward to the next page in the carousel. + ** It updates the scroll position, current page, and sets a flag to indicate programmatic + ** scrolling. The flag is reset after a delay to allow for smooth scrolling. + *********************************************************************************************/ + const onScrollForward = (): void => { + if (!carouselRef.current || currentPage === Math.ceil(apps.length / 4)) return; + set_isProgrammaticScroll(true); + carouselRef.current.scrollLeft += 880; + set_currentPage(prev => prev + 1); + + setTimeout(() => { + set_isProgrammaticScroll(false); + }, 3000); + }; + + /********************************************************************************************** + ** Handles clicking on the carousel dots to navigate to a specific page. + ** It updates the scroll position, current page, and sets a flag to indicate programmatic + ** scrolling. The flag is reset after a delay to allow for smooth scrolling. + *********************************************************************************************/ + const onDotsClick = (destination: number): void => { + if (!carouselRef.current || destination === currentPage) return; + set_isProgrammaticScroll(true); + if (destination > currentPage) { + carouselRef.current.scrollLeft += 1000 * (destination - currentPage); + setTimeout(() => { + set_isProgrammaticScroll(false); + }, 3000); + } else { + carouselRef.current.scrollLeft -= 1000 * (currentPage - destination); + setTimeout(() => { + set_isProgrammaticScroll(false); + }, 3000); + } + set_currentPage(destination); + }; + + /********************************************************************************************** + ** Handles the scroll event of the carousel. + ** It calculates the current page based on the scroll position and updates the state. + ** This function is not triggered during programmatic scrolling to avoid conflicts. + *********************************************************************************************/ + const onScroll = (): void => { + if (!carouselRef.current || isProgrammaticScroll) return; + const {scrollLeft} = carouselRef.current; + const page = Math.ceil(scrollLeft / 1000) + 1; + set_currentPage(page); + }; + + /********************************************************************************************** + ** On component mount we shuffle the array of Partners to avoid any bias. + **********************************************************************************************/ + useMountEffect(() => { + if (apps?.length < 1) { + return; + } + set_shuffledApps(apps?.toSorted(() => 0.5 - Math.random())); + }); + return ( +
+
+
+
{title}
+ +
+ {apps?.length > 4 && ( + + )} +
+ + +
+ ); +}; diff --git a/apps/common/components/Cutaway.tsx b/apps/common/components/Cutaway.tsx new file mode 100644 index 000000000..649764641 --- /dev/null +++ b/apps/common/components/Cutaway.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import {IconShare} from '@common/icons/IconShare'; + +import type {ReactElement} from 'react'; + +type TCutawayProps = { + title: string; + link: string; + icon: ReactElement; +}; + +export function Cutaway(props: TCutawayProps): ReactElement { + return ( + +
+
{props.icon}
+
+

{props.title}

+
+
+
+ +
+ + ); +} diff --git a/apps/common/components/FeaturedApp.tsx b/apps/common/components/FeaturedApp.tsx new file mode 100644 index 000000000..5628ad1b7 --- /dev/null +++ b/apps/common/components/FeaturedApp.tsx @@ -0,0 +1,38 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import {cl} from '@builtbymom/web3/utils'; + +import type {ReactElement} from 'react'; +import type {TApp} from '@common/types/category'; + +export function FeaturedApp(props: {app: TApp}): ReactElement { + return ( + +
+ {props.app.name} +
+ +
+ {props.app.description} +
+ + ); +} diff --git a/apps/common/components/FilterBar.tsx b/apps/common/components/FilterBar.tsx new file mode 100644 index 000000000..64c1035e9 --- /dev/null +++ b/apps/common/components/FilterBar.tsx @@ -0,0 +1,30 @@ +import {cl} from '@builtbymom/web3/utils'; +import {CATEGORY_PAGE_FILTERS} from '@common/utils/constants'; + +import type {ReactElement} from 'react'; + +function FilterItem({isActive, title}: {isActive: boolean; title: string}): ReactElement { + return ( +
+ {title} +
+ ); +} + +export function FilterBar({selectedFilter}: {selectedFilter: {title: string; value: string}}): ReactElement { + return ( +
+ {CATEGORY_PAGE_FILTERS.map(filter => ( + + ))} +
+ ); +} diff --git a/apps/common/components/Header.tsx b/apps/common/components/Header.tsx index cca4b2646..d52c42050 100644 --- a/apps/common/components/Header.tsx +++ b/apps/common/components/Header.tsx @@ -5,10 +5,11 @@ import {useWeb3} from '@builtbymom/web3/contexts/useWeb3'; import {truncateHex} from '@builtbymom/web3/utils/tools.address'; import {useAccountModal, useChainModal} from '@rainbow-me/rainbowkit'; import {LogoPopover} from '@yearn-finance/web-lib/components/LogoPopover'; -import {ModalMobileMenu} from '@yearn-finance/web-lib/components/ModalMobileMenu'; import {IconWallet} from '@yearn-finance/web-lib/icons/IconWallet'; +import {IconBurger} from '@common/icons/IconBurger'; import {AppName, APPS} from './Apps'; +import {ModalMobileMenu} from './ModalMobileMenu'; import type {ReactElement} from 'react'; import type {Chain} from 'viem'; @@ -94,7 +95,7 @@ function AppHeader(props: {supportedNetworks: Chain[]}): ReactElement { const [isMenuOpen, set_isMenuOpen] = useState(false); const menu = useMemo((): TMenu[] => { - const HOME_MENU = {path: '/', label: 'Home'}; + const HOME_MENU = {path: '/apps', label: 'Apps'}; if (pathname.startsWith('/ycrv')) { return [HOME_MENU, ...APPS[AppName.YCRV].menu]; @@ -138,38 +139,7 @@ function AppHeader(props: {supportedNetworks: Chain[]}): ReactElement {
diff --git a/apps/common/components/MobileNavbar.tsx b/apps/common/components/MobileNavbar.tsx new file mode 100644 index 000000000..1ea2a01f1 --- /dev/null +++ b/apps/common/components/MobileNavbar.tsx @@ -0,0 +1,72 @@ +import Link from 'next/link'; +import {usePathname} from 'next/navigation'; +import {cl} from '@builtbymom/web3/utils'; +import {IconDiscord} from '@common/icons/IconDiscord'; +import {IconParagraph} from '@common/icons/IconParagraph'; +import {IconTwitter} from '@common/icons/IconTwitter'; +import {iconsDict, LANDING_SIDEBAR_LINKS, MENU_TABS} from '@common/utils/constants'; + +import type {ReactElement} from 'react'; + +export function MobileNavbar({onClose}: {onClose: VoidFunction}): ReactElement { + const pathName = usePathname(); + + const currentTab = pathName?.startsWith('/apps/') ? pathName?.split('/')[2] : 'apps'; + + return ( +
+
+ {MENU_TABS.map(tab => ( + +
+ {iconsDict[tab.route as keyof typeof iconsDict]} +
+

{tab.title}

+ + ))} +
+ +
+
+ {LANDING_SIDEBAR_LINKS.slice(0, 5).map(link => ( + + {link.title} + + ))} +
+ +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/apps/common/components/MobileTopNav.tsx b/apps/common/components/MobileTopNav.tsx new file mode 100644 index 000000000..bc46c9745 --- /dev/null +++ b/apps/common/components/MobileTopNav.tsx @@ -0,0 +1,79 @@ +import {type ReactElement, useCallback} from 'react'; +import {useRouter} from 'next/router'; +import {useSearch} from '@common/contexts/useSearch'; +import {IconBurgerPlain} from '@common/icons/IconBurgerPlain'; +import {IconCross} from '@common/icons/IconCross'; +import {IconSearch} from '@common/icons/IconSearch'; +import {LogoYearn} from '@common/icons/LogoYearn'; + +import {SearchBar} from './SearchBar'; + +export function MobileTopNav({ + isSearchOpen, + isNavbarOpen, + set_isSearchOpen, + set_isNavbarOpen +}: { + isSearchOpen: boolean; + isNavbarOpen: boolean; + set_isSearchOpen: React.Dispatch>; + set_isNavbarOpen: React.Dispatch>; +}): ReactElement { + const {configuration, dispatch} = useSearch(); + const router = useRouter(); + + const onSearchClick = useCallback(() => { + if (!configuration.searchValue) { + router.push('/apps'); + return; + } + router.push(`/apps/search/${encodeURIComponent(configuration.searchValue)}`); + }, [configuration.searchValue, router]); + + return ( +
+
+
+ + +
+ +
+ + {isSearchOpen && ( +
+ dispatch({searchValue: value})} + searchPlaceholder={'Search App'} + onSearchClick={onSearchClick} + shouldSearchByClick + /> +
+ )} +
+ ); +} diff --git a/apps/common/components/ModalMobileMenu.tsx b/apps/common/components/ModalMobileMenu.tsx new file mode 100644 index 000000000..0c4a8fdfa --- /dev/null +++ b/apps/common/components/ModalMobileMenu.tsx @@ -0,0 +1,152 @@ +'use client'; +import React, {Fragment, useMemo} from 'react'; +import Link from 'next/link'; +import {Dialog, Transition, TransitionChild} from '@headlessui/react'; +import {IconArrow} from '@common/icons/IconArrow'; +import {IconClose} from '@common/icons/IconClose'; +import {IconDiscord} from '@common/icons/IconDiscord'; +import {IconParagraph} from '@common/icons/IconParagraph'; +import {IconTwitter} from '@common/icons/IconTwitter'; +import {LogoYearn} from '@common/icons/LogoYearn'; + +import type {ReactElement, ReactNode} from 'react'; +import type {Chain} from 'viem'; +import type {TMenu} from '@yearn-finance/web-lib/components/Header'; + +export function FooterNav(): ReactElement { + const menu = useMemo((): TMenu[] => { + const HOME_MENU = {path: '/apps', label: 'Apps'}; + + return [ + HOME_MENU, + { + path: 'https://gov.yearn.fi/', + label: 'Governance', + target: '_blank' + }, + {path: 'https://blog.yearn.fi/', label: 'Blog', target: '_blank'}, + {path: 'https://docs.yearn.fi/', label: 'Docs', target: '_blank'}, + {path: 'https://discord.gg/yearn', label: 'Support', target: '_blank'} + ]; + }, []); + + return ( +
+
+ {menu.map(link => ( + + {link.label} + + + ))} +
+
+ + + + + + + + + +
+
+ ); +} + +type TModalMobileMenu = { + isOpen: boolean; + shouldUseWallets: boolean; + shouldUseNetworks: boolean; + onClose: () => void; + children: ReactNode; + supportedNetworks: Chain[]; +}; + +export type TModal = { + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} & React.ComponentPropsWithoutRef<'div'>; + +export function ModalMobileMenu(props: TModalMobileMenu): ReactElement { + const {isOpen, onClose} = props; + + return ( + + +
+ +
+ + + + ​ + + +
+
+ + + + +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/common/components/Pagination.tsx b/apps/common/components/Pagination.tsx index 1f0b0a1da..922b0b9b7 100644 --- a/apps/common/components/Pagination.tsx +++ b/apps/common/components/Pagination.tsx @@ -25,7 +25,7 @@ export function Pagination(props: TProps): ReactElement { role={'button'} href={'#'} className={ - 'border-gray-300 text-gray-700 hover:bg-gray-50 relative inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium' + 'hover:bg-gray-50 relative inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-500' }> {'Previous'} @@ -33,7 +33,7 @@ export function Pagination(props: TProps): ReactElement { role={'button'} href={'#'} className={ - 'border-gray-300 text-gray-700 hover:bg-gray-50 relative ml-3 inline-flex items-center rounded-md border px-4 py-2 text-sm font-medium' + 'hover:bg-gray-50 relative ml-3 inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-500' }> {'Next'} diff --git a/apps/common/components/PromoPoster.tsx b/apps/common/components/PromoPoster.tsx new file mode 100644 index 000000000..e4a474ffc --- /dev/null +++ b/apps/common/components/PromoPoster.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import {IconShare} from '@common/icons/IconShare'; + +import type {ReactElement} from 'react'; + +export function PromoPoster(): ReactElement { + return ( + +
+ {'earn with'} +
{'yearn'} +
+ +
+ +
+ +
+

+ { + 'Yearn is a decentralized suite of products helping individuals, DAOs, and other protocols earn yield on their digital assets.' + } +

+
+ + ); +} diff --git a/apps/common/components/SearchBar.tsx b/apps/common/components/SearchBar.tsx index c1dad29b8..4bc3e61f9 100644 --- a/apps/common/components/SearchBar.tsx +++ b/apps/common/components/SearchBar.tsx @@ -1,4 +1,6 @@ import {cl} from '@builtbymom/web3/utils'; +import {IconEnter} from '@common/icons/IconEnter'; +import {IconSearch} from '@common/icons/IconSearch'; import type {ChangeEvent, ReactElement} from 'react'; @@ -9,6 +11,8 @@ type TSearchBar = { className?: string; iconClassName?: string; inputClassName?: string; + shouldSearchByClick?: boolean; + onSearchClick?: () => void; }; export function SearchBar(props: TSearchBar): ReactElement { @@ -25,7 +29,7 @@ export function SearchBar(props: TSearchBar): ReactElement { suppressHydrationWarning className={cl( props.inputClassName, - 'h-10 w-full overflow-x-scroll border-none bg-transparent px-0 py-2 text-base outline-none scrollbar-none placeholder:text-neutral-400' + 'h-10 w-full overflow-x-scroll border-none bg-transparent pl-2 px-0 py-2 text-base outline-none scrollbar-none placeholder:text-neutral-400' )} type={'text'} placeholder={props.searchPlaceholder} @@ -33,23 +37,24 @@ export function SearchBar(props: TSearchBar): ReactElement { onChange={(e: ChangeEvent): void => { props.onSearch(e.target.value); }} + onKeyDown={e => { + if (!props.shouldSearchByClick) return; + if (e.key === 'Enter') { + return props.onSearchClick?.(); + } + }} /> -
- - - +
props.onSearchClick?.()} + className={cl(props.iconClassName, 'absolute right-0 text-neutral-400')}> + {props.shouldSearchByClick && props.searchValue ? ( +
+ +
+ ) : ( + + )}
diff --git a/apps/common/components/Sidebar.tsx b/apps/common/components/Sidebar.tsx new file mode 100644 index 000000000..26d5afa9e --- /dev/null +++ b/apps/common/components/Sidebar.tsx @@ -0,0 +1,97 @@ +import {type ReactElement, useCallback} from 'react'; +import Link from 'next/link'; +import {usePathname} from 'next/navigation'; +import {useRouter} from 'next/router'; +import {cl} from '@builtbymom/web3/utils'; +import {useSearch} from '@common/contexts/useSearch'; +import {LogoYearn} from '@common/icons/LogoYearn'; +import {iconsDict, LANDING_SIDEBAR_LINKS} from '@common/utils/constants'; + +import {PromoPoster} from './PromoPoster'; +import {SearchBar} from './SearchBar'; + +type TSidebarProps = { + tabs: {route: string; title: string; isAcitve?: boolean}[]; +}; + +export function Sidebar(props: TSidebarProps): ReactElement { + const pathName = usePathname(); + const router = useRouter(); + const {configuration, dispatch} = useSearch(); + + const currentTab = pathName?.startsWith('/apps/') ? pathName?.split('/')[2] : 'apps'; + + const onSearchClick = useCallback(() => { + if (!configuration.searchValue) { + router.push('/apps'); + return; + } + router.push(`/apps/search/${encodeURIComponent(configuration.searchValue)}`); + }, [configuration.searchValue, router]); + + return ( +
+
+
+
+ + + +
+
+ +
+ dispatch({searchValue: value})} + shouldSearchByClick + onSearchClick={onSearchClick} + /> +
+
+ {props.tabs.map(tab => { + const href = tab.route === 'apps' ? `/${tab.route}` : `/apps/${tab.route}`; + return ( + +
+ {iconsDict[tab.route as keyof typeof iconsDict]} +
+

{tab.title}

+ + ); + })} +
+
+ +
+ {LANDING_SIDEBAR_LINKS.map(link => ( + + {link.title} + + ))} +
+
+ ); +} diff --git a/apps/common/components/SortingBar.tsx b/apps/common/components/SortingBar.tsx new file mode 100644 index 000000000..d11e19ea7 --- /dev/null +++ b/apps/common/components/SortingBar.tsx @@ -0,0 +1,36 @@ +import {type ReactElement, useState} from 'react'; +import {IconChevron} from '@common/icons/IconChevron'; + +function SortItem({isActive, title}: {isActive: boolean; title: string}): ReactElement { + return
{title}
; +} + +export function SortingBar(): ReactElement { + const [isOpen, set_isOpen] = useState(false); + return ( + <> + + {isOpen ? ( +
+ {Array(4) + .fill('List Item') + .map((item, i) => ( + + ))} +
+ ) : null} + + ); +} diff --git a/apps/common/components/WithFonts.tsx b/apps/common/components/WithFonts.tsx index 8abf90cda..4a807a705 100644 --- a/apps/common/components/WithFonts.tsx +++ b/apps/common/components/WithFonts.tsx @@ -8,6 +8,11 @@ const aeonik = localFont({ variable: '--font-aeonik', display: 'swap', src: [ + { + path: '../../../public/fonts/Aeonik-Light.ttf', + weight: '300', + style: 'normal' + }, { path: '../../../public/fonts/Aeonik-Regular.woff2', weight: '400', @@ -26,6 +31,23 @@ const aeonik = localFont({ ] }); +const aeonikFono = localFont({ + variable: '--font-aeonik-fono', + display: 'swap', + src: [ + { + path: '../../../public/fonts/AeonikFono-Light.otf', + weight: '300', + style: 'normal' + }, + { + path: '../../../public/fonts/AeonikFono-Regular.otf', + weight: '400', + style: 'normal' + } + ] +}); + const sourceCodePro = Source_Code_Pro({ weight: ['400', '500', '600', '700'], subsets: ['latin'], @@ -35,13 +57,17 @@ const sourceCodePro = Source_Code_Pro({ export function WithFonts({children}: {children: ReactNode}): ReactElement { return ( -
+