434 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			434 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 'use client'
 | |
| 
 | |
| import { Fragment, useEffect, useId, useRef, useState } from 'react'
 | |
| import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
 | |
| import clsx from 'clsx'
 | |
| import {
 | |
|   type MotionProps,
 | |
|   type Variant,
 | |
|   AnimatePresence,
 | |
|   motion,
 | |
| } from 'framer-motion'
 | |
| import { useDebouncedCallback } from 'use-debounce'
 | |
| 
 | |
| import { AppScreen } from './AppScreen'
 | |
| import {
 | |
|   Eyebrow,
 | |
|   FeatureDescription,
 | |
|   FeatureTitle,
 | |
|   MobileFeatureTitle,
 | |
|   P,
 | |
|   SectionHeader,
 | |
| } from '@/components/Texts'
 | |
| import { CircleBackground } from '@/components/CircleBackground'
 | |
| import { Container } from '@/components/Container'
 | |
| 
 | |
| import connectorImg from '@/images/connector.png'
 | |
| import peersImg from '@/images/peers.png'
 | |
| import settingImg from '@/images/setting.png'
 | |
| import { PhoneFrame } from '@/components/PhoneFrame'
 | |
| 
 | |
| 
 | |
| interface CustomAnimationProps {
 | |
|   isForwards: boolean
 | |
|   changeCount: number
 | |
| }
 | |
| 
 | |
| const features = [
 | |
|   {
 | |
|     name: 'Mycelium Connector',
 | |
|     description:
 | |
|       "Start (and stop) your Mycelium connector to gain access to sites, apps, and workloads available exclusively on the Mycelium Network. View statistics around peers and traffic.",
 | |
|     icon: DeviceUserIcon,
 | |
|     screen: InviteScreen,
 | |
|   },
 | |
|   {
 | |
|     name: 'Mycelium Peers',
 | |
|     description:
 | |
|       'Search and discover active peers on the Mycelium Network, or add your own.',
 | |
|     icon: DeviceNotificationIcon,
 | |
|     screen: StocksScreen,
 | |
|   },
 | |
|   {
 | |
|     name: 'Network Setting',
 | |
|     description:
 | |
|       'Find version and network information and trigger light or dark mode.',
 | |
|     icon: DeviceTouchIcon,
 | |
|     screen: InvestScreen,
 | |
|   },
 | |
| ]
 | |
| 
 | |
| function DeviceUserIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
 | |
|   return (
 | |
|     <svg viewBox="0 0 32 32" aria-hidden="true" {...props}>
 | |
|       <circle cx={16} cy={16} r={16} fill="#A3A3A3" fillOpacity={0.2} />
 | |
|       <path
 | |
|         fillRule="evenodd"
 | |
|         clipRule="evenodd"
 | |
|         d="M16 23a3 3 0 100-6 3 3 0 000 6zm-1 2a4 4 0 00-4 4v1a2 2 0 002 2h6a2 2 0 002-2v-1a4 4 0 00-4-4h-2z"
 | |
|         fill="#737373"
 | |
|       />
 | |
|       <path
 | |
|         fillRule="evenodd"
 | |
|         clipRule="evenodd"
 | |
|         d="M5 4a4 4 0 014-4h14a4 4 0 014 4v24a4.002 4.002 0 01-3.01 3.877c-.535.136-.99-.325-.99-.877s.474-.98.959-1.244A2 2 0 0025 28V4a2 2 0 00-2-2h-1.382a1 1 0 00-.894.553l-.448.894a1 1 0 01-.894.553h-6.764a1 1 0 01-.894-.553l-.448-.894A1 1 0 0010.382 2H9a2 2 0 00-2 2v24a2 2 0 001.041 1.756C8.525 30.02 9 30.448 9 31s-.455 1.013-.99.877A4.002 4.002 0 015 28V4z"
 | |
|         fill="#A3A3A3"
 | |
|       />
 | |
|     </svg>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function DeviceNotificationIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
 | |
|   return (
 | |
|     <svg viewBox="0 0 32 32" aria-hidden="true" {...props}>
 | |
|       <circle cx={16} cy={16} r={16} fill="#A3A3A3" fillOpacity={0.2} />
 | |
|       <path
 | |
|         fillRule="evenodd"
 | |
|         clipRule="evenodd"
 | |
|         d="M9 0a4 4 0 00-4 4v24a4 4 0 004 4h14a4 4 0 004-4V4a4 4 0 00-4-4H9zm0 2a2 2 0 00-2 2v24a2 2 0 002 2h14a2 2 0 002-2V4a2 2 0 00-2-2h-1.382a1 1 0 00-.894.553l-.448.894a1 1 0 01-.894.553h-6.764a1 1 0 01-.894-.553l-.448-.894A1 1 0 0010.382 2H9z"
 | |
|         fill="#A3A3A3"
 | |
|       />
 | |
|       <path
 | |
|         d="M9 8a2 2 0 012-2h10a2 2 0 012 2v2a2 2 0 01-2 2H11a2 2 0 01-2-2V8z"
 | |
|         fill="#737373"
 | |
|       />
 | |
|     </svg>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function DeviceTouchIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
 | |
|   let id = useId()
 | |
| 
 | |
|   return (
 | |
|     <svg viewBox="0 0 32 32" fill="none" aria-hidden="true" {...props}>
 | |
|       <defs>
 | |
|         <linearGradient
 | |
|           id={`${id}-gradient`}
 | |
|           x1={14}
 | |
|           y1={14.5}
 | |
|           x2={7}
 | |
|           y2={17}
 | |
|           gradientUnits="userSpaceOnUse"
 | |
|         >
 | |
|           <stop stopColor="#737373" />
 | |
|           <stop offset={1} stopColor="#D4D4D4" stopOpacity={0} />
 | |
|         </linearGradient>
 | |
|       </defs>
 | |
|       <circle cx={16} cy={16} r={16} fill="#A3A3A3" fillOpacity={0.2} />
 | |
|       <path
 | |
|         fillRule="evenodd"
 | |
|         clipRule="evenodd"
 | |
|         d="M5 4a4 4 0 014-4h14a4 4 0 014 4v13h-2V4a2 2 0 00-2-2h-1.382a1 1 0 00-.894.553l-.448.894a1 1 0 01-.894.553h-6.764a1 1 0 01-.894-.553l-.448-.894A1 1 0 0010.382 2H9a2 2 0 00-2 2v24a2 2 0 002 2h4v2H9a4 4 0 01-4-4V4z"
 | |
|         fill="#A3A3A3"
 | |
|       />
 | |
|       <path
 | |
|         d="M7 22c0-4.694 3.5-8 8-8"
 | |
|         stroke={`url(#${id}-gradient)`}
 | |
|         strokeWidth={2}
 | |
|         strokeLinecap="round"
 | |
|         strokeLinejoin="round"
 | |
|       />
 | |
|       <path
 | |
|         d="M21 20l.217-5.513a1.431 1.431 0 00-2.85-.226L17.5 21.5l-1.51-1.51a2.107 2.107 0 00-2.98 0 .024.024 0 00-.005.024l3.083 9.25A4 4 0 0019.883 32H25a4 4 0 004-4v-5a3 3 0 00-3-3h-5z"
 | |
|         fill="#A3A3A3"
 | |
|       />
 | |
|     </svg>
 | |
|   )
 | |
| }
 | |
| 
 | |
| const maxZIndex = 2147483647
 | |
| 
 | |
| const bodyVariantBackwards: Variant = {
 | |
|   opacity: 0.4,
 | |
|   scale: 0.8,
 | |
|   zIndex: 0,
 | |
|   filter: 'blur(4px)',
 | |
|   transition: { duration: 0.4 },
 | |
| }
 | |
| 
 | |
| 
 | |
| const bodyAnimation: MotionProps = {
 | |
|   initial: 'initial',
 | |
|   animate: 'animate',
 | |
|   exit: 'exit',
 | |
|   variants: {
 | |
|     initial: (custom: CustomAnimationProps) => (
 | |
|       custom.isForwards
 | |
|         ? {
 | |
|             y: '100%',
 | |
|             zIndex: maxZIndex - custom.changeCount,
 | |
|             transition: { duration: 0.4 },
 | |
|           }
 | |
|         : bodyVariantBackwards
 | |
|     ),
 | |
|     animate: (custom: CustomAnimationProps) => ({
 | |
|       y: '0%',
 | |
|       opacity: 1,
 | |
|       scale: 1,
 | |
|       zIndex: maxZIndex / 2 - custom.changeCount,
 | |
|       filter: 'blur(0px)',
 | |
|       transition: { duration: 0.4 },
 | |
|     }),
 | |
|     exit: (custom: CustomAnimationProps) => (
 | |
|       custom.isForwards
 | |
|         ? bodyVariantBackwards
 | |
|         : {
 | |
|             y: '100%',
 | |
|             zIndex: maxZIndex - custom.changeCount,
 | |
|             transition: { duration: 0.4 },
 | |
|           }
 | |
|     ),
 | |
|   },
 | |
| }
 | |
| 
 | |
| 
 | |
| function InviteScreen() {
 | |
|   return (
 | |
|     <AppScreen className="w-full">
 | |
|       <img src={connectorImg} alt="Mycelium Connector" width="366" height="732" className="mt-[-2rem]" />
 | |
|     </AppScreen>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function StocksScreen() {
 | |
|   return (
 | |
|     <AppScreen className="w-full">
 | |
|       <img src={peersImg} alt="Mycelium Peers" width="366" height="732" className="mt-[-2rem]" />
 | |
|     </AppScreen>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function InvestScreen() {
 | |
|   return (
 | |
|     <AppScreen className="w-full">
 | |
|       <img src={settingImg} alt="Mycelium Settings" width="366" height="732" className="mt-[-2rem]" />
 | |
|     </AppScreen>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function usePrevious<T>(value: T) {
 | |
|   const ref = useRef<T>()
 | |
| 
 | |
|   useEffect(() => {
 | |
|     ref.current = value
 | |
|   }, [value])
 | |
| 
 | |
|   return ref.current
 | |
| }
 | |
| 
 | |
| function FeaturesDesktop() {
 | |
|   let [changeCount, setChangeCount] = useState(0)
 | |
|   let [selectedIndex, setSelectedIndex] = useState(0)
 | |
|   let prevIndex = usePrevious(selectedIndex)
 | |
|   let isForwards = prevIndex === undefined ? true : selectedIndex > prevIndex
 | |
| 
 | |
|   let onChange = useDebouncedCallback(
 | |
|     (selectedIndex: number) => {
 | |
|       setSelectedIndex(selectedIndex)
 | |
|       setChangeCount((changeCount) => changeCount + 1)
 | |
|     },
 | |
|     100,
 | |
|     { leading: true },
 | |
|   )
 | |
| 
 | |
|   return (
 | |
|     <TabGroup
 | |
|       className="grid grid-cols-12 items-center gap-8 lg:gap-16"
 | |
|       selectedIndex={selectedIndex}
 | |
|       onChange={onChange}
 | |
|       vertical
 | |
|     >
 | |
|       <TabList className="z-10 order-last col-span-6 space-y-6">
 | |
|         {features.map((feature, featureIndex) => (
 | |
|           <div
 | |
|             key={feature.name}
 | |
|             className={clsx(
 | |
|               'relative rounded-2xl outline-2 transition-all duration-300 ease-in-out hover:scale-105 hover:bg-gray-800/30',
 | |
|               selectedIndex === featureIndex
 | |
|                 ? 'outline-cyan-500'
 | |
|                 : 'outline-transparent hover:outline-cyan-500',
 | |
|             )}
 | |
|           >
 | |
|             {featureIndex === selectedIndex && (
 | |
|               <motion.div
 | |
|                 layoutId="activeBackground"
 | |
|                 className="absolute inset-0 bg-gray-800"
 | |
|                 initial={{ borderRadius: 16 }}
 | |
|               />
 | |
|             )}
 | |
|             <div className="relative z-10 p-8">
 | |
|               <feature.icon className="h-8 w-8" />
 | |
|               <FeatureTitle as="h3" color="white" className="mt-6">
 | |
|                 <Tab className="text-left data-selected:not-data-focus:outline-hidden">
 | |
|                   <span className="absolute inset-0 rounded-2xl" />
 | |
|                   {feature.name}
 | |
|                 </Tab>
 | |
|               </FeatureTitle>
 | |
|               <FeatureDescription color="secondary" className="mt-2">
 | |
|                 {feature.description}
 | |
|               </FeatureDescription>
 | |
|             </div>
 | |
|           </div>
 | |
|         ))}
 | |
|       </TabList>
 | |
|       <div className="relative col-span-6">
 | |
|         <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
 | |
|           <CircleBackground id="primaryfeatures_desktop_circle" color="#13B5C8" className="animate-spin-slower" />
 | |
|         </div>
 | |
|         <PhoneFrame className="z-10 mx-auto w-full max-w-[366px]">
 | |
|           <TabPanels as={Fragment}>
 | |
|             <AnimatePresence
 | |
|               initial={false}
 | |
|               custom={{ isForwards, changeCount }}
 | |
|             >
 | |
|               {features.map((feature, featureIndex) =>
 | |
|                 selectedIndex === featureIndex ? (
 | |
|                   <TabPanel
 | |
|                     static
 | |
|                     key={feature.name + changeCount}
 | |
|                     className="col-start-1 row-start-1 flex focus:outline-offset-32 data-selected:not-data-focus:outline-hidden"
 | |
|                   >
 | |
|                     <motion.div {...bodyAnimation} custom={{ isForwards, changeCount }}>
 | |
|                       <feature.screen />
 | |
|                     </motion.div>
 | |
|                   </TabPanel>
 | |
|                 ) : null,
 | |
|               )}
 | |
|             </AnimatePresence>
 | |
|           </TabPanels>
 | |
|         </PhoneFrame>
 | |
|       </div>
 | |
|     </TabGroup>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function FeaturesMobile() {
 | |
|   let [activeIndex, setActiveIndex] = useState(0)
 | |
|   let slideContainerRef = useRef<React.ElementRef<'div'>>(null)
 | |
|   let slideRefs = useRef<Array<React.ElementRef<'div'>>>([])
 | |
| 
 | |
|   useEffect(() => {
 | |
|     let observer = new window.IntersectionObserver(
 | |
|       (entries) => {
 | |
|         for (let entry of entries) {
 | |
|           if (entry.isIntersecting && entry.target instanceof HTMLDivElement) {
 | |
|             setActiveIndex(slideRefs.current.indexOf(entry.target))
 | |
|             break
 | |
|           }
 | |
|         }
 | |
|       },
 | |
|       {
 | |
|         root: slideContainerRef.current,
 | |
|         threshold: 0.6,
 | |
|       },
 | |
|     )
 | |
| 
 | |
|     for (let slide of slideRefs.current) {
 | |
|       if (slide) {
 | |
|         observer.observe(slide)
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return () => {
 | |
|       observer.disconnect()
 | |
|     }
 | |
|   }, [slideContainerRef, slideRefs])
 | |
| 
 | |
|   return (
 | |
|     <>
 | |
|       <div
 | |
|         ref={slideContainerRef}
 | |
|         className="-mb-4 flex snap-x snap-mandatory -space-x-4 overflow-x-auto overscroll-x-contain scroll-smooth pb-4 [scrollbar-width:none] sm:-space-x-6 [&::-webkit-scrollbar]:hidden"
 | |
|       >
 | |
|         {features.map((feature, featureIndex) => (
 | |
|           <div
 | |
|             key={featureIndex}
 | |
|             ref={(ref) => ref && (slideRefs.current[featureIndex] = ref)}
 | |
|             className="w-full flex-none snap-center px-4 sm:px-6 transition-all duration-300 ease-in-out hover:scale-105"
 | |
|           >
 | |
|                         <div
 | |
|               className={clsx(
 | |
|                 'relative transform overflow-hidden rounded-2xl bg-gray-800 px-5 py-6 outline-2 transition-colors',
 | |
|                 activeIndex === featureIndex
 | |
|                   ? 'outline-transparent' // Remove outline for active mobile slide
 | |
|                   : 'outline-transparent hover:outline-cyan-500',
 | |
|               )}
 | |
|             >
 | |
|               <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
 | |
|                                 <CircleBackground
 | |
|                   id={`primaryfeatures_mobile_circle_${featureIndex}`}
 | |
|                   color="#13B5C8"
 | |
|                   className={featureIndex % 2 === 1 ? 'rotate-180' : undefined}
 | |
|                 />
 | |
|               </div>
 | |
|               <PhoneFrame className="relative mx-auto w-full max-w-[366px]">
 | |
|                 <feature.screen />
 | |
|               </PhoneFrame>
 | |
|               <div className="absolute inset-x-0 bottom-0 bg-gray-800/95 p-6 backdrop-blur-sm sm:p-10">
 | |
|                 <feature.icon className="h-8 w-8" />
 | |
|                 <MobileFeatureTitle color="white" className="mt-6">
 | |
|                   {feature.name}
 | |
|                 </MobileFeatureTitle>
 | |
|                 <FeatureDescription color="secondary" className="mt-2">
 | |
|                   {feature.description}
 | |
|                 </FeatureDescription>
 | |
|               </div>
 | |
|             </div>
 | |
|           </div>
 | |
|         ))}
 | |
|       </div>
 | |
|       <div className="mt-6 flex justify-center gap-3">
 | |
|         {features.map((_, featureIndex) => (
 | |
|           <button
 | |
|             type="button"
 | |
|             key={featureIndex}
 | |
|             className={clsx(
 | |
|               'relative h-0.5 w-4 rounded-full',
 | |
|               featureIndex === activeIndex ? 'bg-gray-300' : 'bg-gray-500',
 | |
|             )}
 | |
|             aria-label={`Go to slide ${featureIndex + 1}`}
 | |
|             onClick={() => {
 | |
|               slideRefs.current[featureIndex].scrollIntoView({
 | |
|                 block: 'nearest',
 | |
|                 inline: 'nearest',
 | |
|               })
 | |
|             }}
 | |
|           >
 | |
|             <span className="absolute -inset-x-1.5 -inset-y-3" />
 | |
|           </button>
 | |
|         ))}
 | |
|       </div>
 | |
|     </>
 | |
|   )
 | |
| }
 | |
| 
 | |
| export function PrimaryFeatures() {
 | |
|   return (
 | |
|     <section
 | |
|       id="howitworks"
 | |
|       aria-label="Features for investing all your money"
 | |
|       className="bg-gray-900 py-20 sm:py-32"
 | |
|     >
 | |
|       <Container>
 | |
|         <div className="mx-auto max-w-2xl lg:mx-0 lg:max-w-3xl">
 | |
|           <Eyebrow color="accent">How It Works</Eyebrow>
 | |
|           <SectionHeader color="white" className="mt-2">
 | |
|             How Mycelium Operates
 | |
|           </SectionHeader>
 | |
|           <P color="light" className="mt-6">
 | |
|             Mycelium, like its natural namesake, thrives on decentralization,
 | |
|             efficiency, and security, making it a truly powerful force in the world
 | |
|             of decentralized networks.
 | |
|           </P>
 | |
|         </div>
 | |
|       </Container>
 | |
|       <div className="mt-16 md:hidden">
 | |
|         <FeaturesMobile />
 | |
|       </div>
 | |
|       <Container className="hidden md:mt-20 md:block">
 | |
|         <FeaturesDesktop />
 | |
|       </Container>
 | |
|     </section>
 | |
|   )
 | |
| }
 |