www_threefold_2025/src/components/PrimaryFeatures.tsx
2025-06-18 17:23:42 +02:00

599 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
type Variants,
AnimatePresence,
motion,
} from 'framer-motion'
import { useDebouncedCallback } from 'use-debounce'
import { AppScreen } from '@/components/AppScreen'
import { CircleBackground } from '@/components/CircleBackground'
import { Container } from '@/components/Container'
import { PhoneFrame } from '@/components/PhoneFrame'
import {
DiageoLogo,
LaravelLogo,
MirageLogo,
ReversableLogo,
StatamicLogo,
StaticKitLogo,
TransistorLogo,
TupleLogo,
} from '@/components/StockLogos'
const MotionAppScreenHeader = motion(AppScreen.Header)
const MotionAppScreenBody = motion(AppScreen.Body)
interface CustomAnimationProps {
isForwards: boolean
changeCount: number
}
const features = [
{
name: 'Invite friends for better returns',
description:
'For every friend you invite to EngageOS, you get insider notifications 5 seconds sooner. And its 10 seconds if you invite an insider.',
icon: DeviceUserIcon,
screen: InviteScreen,
},
{
name: 'Notifications on stock dips',
description:
'Get a push notification every time we find out something thats going to lower the share price on your holdings so you can sell before the information hits the public markets.',
icon: DeviceNotificationIcon,
screen: StocksScreen,
},
{
name: 'Invest what you want',
description:
'We hide your stock purchases behind thousands of anonymous trading accounts, so suspicious activity can never be traced back to you.',
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 headerAnimation: Variants = {
initial: { opacity: 0, transition: { duration: 0.3 } },
animate: { opacity: 1, transition: { duration: 0.3, delay: 0.3 } },
exit: { opacity: 0, transition: { duration: 0.3 } },
}
const maxZIndex = 2147483647
const bodyVariantBackwards: Variant = {
opacity: 0.4,
scale: 0.8,
zIndex: 0,
filter: 'blur(4px)',
transition: { duration: 0.4 },
}
const bodyVariantForwards: Variant = (custom: CustomAnimationProps) => ({
y: '100%',
zIndex: maxZIndex - custom.changeCount,
transition: { duration: 0.4 },
})
const bodyAnimation: MotionProps = {
initial: 'initial',
animate: 'animate',
exit: 'exit',
variants: {
initial: (custom: CustomAnimationProps, ...props) =>
custom.isForwards
? bodyVariantForwards(custom, ...props)
: 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, ...props) =>
custom.isForwards
? bodyVariantBackwards
: bodyVariantForwards(custom, ...props),
},
}
type ScreenProps =
| {
animated: true
custom: CustomAnimationProps
}
| { animated?: false }
function InviteScreen(props: ScreenProps) {
return (
<AppScreen className="w-full">
<MotionAppScreenHeader {...(props.animated ? headerAnimation : {})}>
<AppScreen.Title>Invite people</AppScreen.Title>
<AppScreen.Subtitle>
Get tips <span className="text-white">5s sooner</span> for every
invite.
</AppScreen.Subtitle>
</MotionAppScreenHeader>
<MotionAppScreenBody
{...(props.animated ? { ...bodyAnimation, custom: props.custom } : {})}
>
<div className="px-4 py-6">
<div className="space-y-6">
{[
{ label: 'Full name', value: 'Albert H. Wiggin' },
{ label: 'Email address', value: 'awiggin@chase.com' },
].map((field) => (
<div key={field.label}>
<div className="text-sm text-gray-500">{field.label}</div>
<div className="mt-2 border-b border-gray-200 pb-2 text-sm text-gray-900">
{field.value}
</div>
</div>
))}
</div>
<div className="mt-6 rounded-lg bg-cyan-500 px-3 py-2 text-center text-sm font-semibold text-white">
Invite person
</div>
</div>
</MotionAppScreenBody>
</AppScreen>
)
}
function StocksScreen(props: ScreenProps) {
return (
<AppScreen className="w-full">
<MotionAppScreenHeader {...(props.animated ? headerAnimation : {})}>
<AppScreen.Title>Stocks</AppScreen.Title>
<AppScreen.Subtitle>March 9, 2022</AppScreen.Subtitle>
</MotionAppScreenHeader>
<MotionAppScreenBody
{...(props.animated ? { ...bodyAnimation, custom: props.custom } : {})}
>
<div className="divide-y divide-gray-100">
{[
{
name: 'Laravel',
price: '4,098.01',
change: '+4.98%',
color: '#F9322C',
logo: LaravelLogo,
},
{
name: 'Tuple',
price: '5,451.10',
change: '-3.38%',
color: '#5A67D8',
logo: TupleLogo,
},
{
name: 'Transistor',
price: '4,098.41',
change: '+6.25%',
color: '#2A5B94',
logo: TransistorLogo,
},
{
name: 'Diageo',
price: '250.65',
change: '+1.25%',
color: '#3320A7',
logo: DiageoLogo,
},
{
name: 'StaticKit',
price: '250.65',
change: '-3.38%',
color: '#2A3034',
logo: StaticKitLogo,
},
{
name: 'Statamic',
price: '5,040.85',
change: '-3.11%',
color: '#0EA5E9',
logo: StatamicLogo,
},
{
name: 'Mirage',
price: '140.44',
change: '+9.09%',
color: '#16A34A',
logo: MirageLogo,
},
{
name: 'Reversable',
price: '550.60',
change: '-1.25%',
color: '#8D8D8D',
logo: ReversableLogo,
},
].map((stock) => (
<div key={stock.name} className="flex items-center gap-4 px-4 py-3">
<div
className="flex-none rounded-full"
style={{ backgroundColor: stock.color }}
>
<stock.logo className="h-10 w-10" />
</div>
<div className="flex-auto text-sm text-gray-900">
{stock.name}
</div>
<div className="flex-none text-right">
<div className="text-sm font-medium text-gray-900">
{stock.price}
</div>
<div
className={clsx(
'text-xs/5',
stock.change.startsWith('+')
? 'text-cyan-500'
: 'text-gray-500',
)}
>
{stock.change}
</div>
</div>
</div>
))}
</div>
</MotionAppScreenBody>
</AppScreen>
)
}
function InvestScreen(props: ScreenProps) {
return (
<AppScreen className="w-full">
<MotionAppScreenHeader {...(props.animated ? headerAnimation : {})}>
<AppScreen.Title>Buy $LA</AppScreen.Title>
<AppScreen.Subtitle>
<span className="text-white">$34.28</span> per share
</AppScreen.Subtitle>
</MotionAppScreenHeader>
<MotionAppScreenBody
{...(props.animated ? { ...bodyAnimation, custom: props.custom } : {})}
>
<div className="px-4 py-6">
<div className="space-y-4">
{[
{ label: 'Number of shares', value: '100' },
{
label: 'Current market price',
value: (
<div className="flex">
$34.28
<svg viewBox="0 0 24 24" fill="none" className="h-6 w-6">
<path
d="M17 15V7H9M17 7 7 17"
stroke="#06B6D4"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
),
},
{ label: 'Estimated cost', value: '$3,428.00' },
].map((item) => (
<div
key={item.label}
className="flex justify-between border-b border-gray-100 pb-4"
>
<div className="text-sm text-gray-500">{item.label}</div>
<div className="text-sm font-semibold text-gray-900">
{item.value}
</div>
</div>
))}
<div className="rounded-lg bg-cyan-500 px-3 py-2 text-center text-sm font-semibold text-white">
Buy shares
</div>
</div>
</div>
</MotionAppScreenBody>
</AppScreen>
)
}
function usePrevious<T>(value: T) {
let 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) => {
setSelectedIndex(selectedIndex)
setChangeCount((changeCount) => changeCount + 1)
},
100,
{ leading: true },
)
return (
<TabGroup
className="grid grid-cols-12 items-center gap-8 lg:gap-16 xl:gap-24"
selectedIndex={selectedIndex}
onChange={onChange}
vertical
>
<TabList className="relative z-10 order-last col-span-6 space-y-6">
{features.map((feature, featureIndex) => (
<div
key={feature.name}
className="relative rounded-2xl transition-colors hover:bg-gray-800/30"
>
{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" />
<h3 className="mt-6 lg:text-lg text-base font-semibold text-white">
<Tab className="text-left data-selected:not-data-focus:outline-hidden">
<span className="absolute inset-0 rounded-2xl" />
{feature.name}
</Tab>
</h3>
<p className="mt-2 text-sm text-gray-400">
{feature.description}
</p>
</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 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"
>
<feature.screen
animated
custom={{ isForwards, changeCount }}
/>
</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"
>
<div className="relative transform overflow-hidden rounded-2xl bg-gray-800 px-5 py-6">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<CircleBackground
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" />
<h3 className="mt-6 text-sm font-semibold text-white sm:lg:text-lg text-base">
{feature.name}
</h3>
<p className="mt-2 text-sm text-gray-400">
{feature.description}
</p>
</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="features"
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">
<h2 className="text-3xl font-medium tracking-tight text-white">
Every feature you need to win. Try it for yourself.
</h2>
<p className="mt-2 lg:text-lg text-base text-gray-400">
EngageOS was built for investors like you who play by their own rules
and arent going to let SEC regulations get in the way of their
dreams. If other investing tools are afraid to build it, EngageOS has
it.
</p>
</div>
</Container>
<div className="mt-16 md:hidden">
<FeaturesMobile />
</div>
<Container className="hidden md:mt-20 md:block">
<FeaturesDesktop />
</Container>
</section>
)
}