diff --git a/package.json b/package.json index d338670..41b8f3d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "clsx": "^2.1.1", "cross-env": "^7.0.3", "dotenv": "16.4.7", + "framer-motion": "^12.40.0", "geist": "^1.3.0", "graphql": "^16.8.2", "lucide-react": "0.563.0", @@ -52,6 +53,7 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "7.71.1", + "react-use-measure": "^2.1.7", "sharp": "0.34.2", "tailwind-merge": "^3.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a455b65..297200a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: dotenv: specifier: 16.4.7 version: 16.4.7 + framer-motion: + specifier: ^12.40.0 + version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) geist: specifier: ^1.3.0 version: 1.7.1(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4)) @@ -98,6 +101,9 @@ importers: react-hook-form: specifier: 7.71.1 version: 7.71.1(react@19.2.6) + react-use-measure: + specifier: ^2.1.7 + version: 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sharp: specifier: 0.34.2 version: 0.34.2 @@ -3057,6 +3063,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3809,6 +3829,12 @@ packages: resolution: {integrity: sha512-RhQ4DzmBi5BNGcS0w4u1vdMRIKcteXTCNzDt1j7XRcdWYBz1MjMjulBhPaeC5jBCHOD1yinuOFTTSOWLLGexWw==} engines: {node: '>=16.20.1'} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + mpath@0.8.4: resolution: {integrity: sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==} engines: {node: '>=4.0.0'} @@ -4183,6 +4209,15 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -8081,6 +8116,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + fsevents@2.3.2: optional: true @@ -8935,6 +8979,12 @@ snapshots: - socks - supports-color + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + mpath@0.8.4: {} mpath@0.9.0: {} @@ -9362,6 +9412,12 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + react-use-measure@2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} readable-stream@3.6.2: diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 27a45a1..1c51b3f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,52 +1,56 @@ -import { cn } from '@/utilities/ui' -import { Slot } from '@radix-ui/react-slot' -import { type VariantProps, cva } from 'class-variance-authority' -import * as React from 'react' +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utilities/ui" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0", + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { - default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline', + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", }, size: { - clear: '', - default: 'h-10 px-4 py-2 has-[>svg]:px-3', - sm: 'h-9 rounded-md px-3 has-[>svg]:px-2.5', - lg: 'h-11 rounded-md px-8 has-[>svg]:px-4', - icon: 'size-10', + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, }, ) export interface ButtonProps - extends React.ComponentProps<'button'>, + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } -const Button: React.FC = ({ asChild = false, className, size, variant, ...props }) => { - const Comp = asChild ? Slot : 'button' - - return ( - - ) -} +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" export { Button, buttonVariants } diff --git a/src/components/ui/header-1.tsx b/src/components/ui/header-1.tsx new file mode 100644 index 0000000..8af03be --- /dev/null +++ b/src/components/ui/header-1.tsx @@ -0,0 +1,134 @@ +'use client'; +import React from 'react'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { cn } from '@/utilities/ui'; +import { MenuToggleIcon } from '@/components/ui/menu-toggle-icon'; +import { useScroll } from '@/components/ui/use-scroll'; +import { createPortal } from 'react-dom'; + +export function Header() { + const [open, setOpen] = React.useState(false); + const scrolled = useScroll(10); + + const links = [ + { + label: 'Features', + href: '#', + }, + { + label: 'Pricing', + href: '#', + }, + { + label: 'About', + href: '#', + }, + ]; + + React.useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + return ( +
+ + +
+ {links.map((link) => ( + + {link.label} + + ))} +
+
+ + +
+
+
+ ); +} + +type MobileMenuProps = React.ComponentProps<'div'> & { + open: boolean; +}; + +function MobileMenu({ open, children, className, ...props }: MobileMenuProps) { + if (!open || typeof window === 'undefined') return null; + + return createPortal( +
+
+ {children} +
+
, + document.body, + ); +} + + + +export const WordmarkIcon = (props: React.ComponentProps<"svg">) => ( + + + +); diff --git a/src/components/ui/hero-1.tsx b/src/components/ui/hero-1.tsx new file mode 100644 index 0000000..ea72333 --- /dev/null +++ b/src/components/ui/hero-1.tsx @@ -0,0 +1,130 @@ +import { cn } from "@/utilities/ui"; +import { Button } from "@/components/ui/button"; +import { RocketIcon, ArrowRightIcon, PhoneCallIcon } from "lucide-react"; +import { LogoCloud } from "@/components/ui/logo-cloud-3"; + +export function HeroSection() { + return ( +
+ {/* Top Shades */} +
+ ); +} + +export function LogosSection() { + return ( +
+

+ Trusted by experts +

+
+ +
+
+ ); +} + +const logos = [ + { + src: "https://storage.efferd.com/logo/nvidia-wordmark.svg", + alt: "Nvidia Logo", + }, + { + src: "https://storage.efferd.com/logo/supabase-wordmark.svg", + alt: "Supabase Logo", + }, + { + src: "https://storage.efferd.com/logo/openai-wordmark.svg", + alt: "OpenAI Logo", + }, + { + src: "https://storage.efferd.com/logo/turso-wordmark.svg", + alt: "Turso Logo", + }, + { + src: "https://storage.efferd.com/logo/vercel-wordmark.svg", + alt: "Vercel Logo", + }, + { + src: "https://storage.efferd.com/logo/github-wordmark.svg", + alt: "GitHub Logo", + }, + { + src: "https://storage.efferd.com/logo/claude-wordmark.svg", + alt: "Claude AI Logo", + }, + { + src: "https://storage.efferd.com/logo/clerk-wordmark.svg", + alt: "Clerk Logo", + }, +]; \ No newline at end of file diff --git a/src/components/ui/infinite-slider.tsx b/src/components/ui/infinite-slider.tsx new file mode 100644 index 0000000..61d55c5 --- /dev/null +++ b/src/components/ui/infinite-slider.tsx @@ -0,0 +1,107 @@ +'use client'; +import { cn } from '@/utilities/ui'; +import { useMotionValue, animate, motion } from 'framer-motion'; +import { useState, useEffect } from 'react'; +import useMeasure from 'react-use-measure'; + +type InfiniteSliderProps = { + children: React.ReactNode; + gap?: number; + duration?: number; + durationOnHover?: number; + direction?: 'horizontal' | 'vertical'; + reverse?: boolean; + className?: string; +}; + +export function InfiniteSlider({ + children, + gap = 16, + duration = 25, + durationOnHover, + direction = 'horizontal', + reverse = false, + className, +}: InfiniteSliderProps) { + const [currentDuration, setCurrentDuration] = useState(duration); + const [ref, { width, height }] = useMeasure(); + const translation = useMotionValue(0); + const [isTransitioning, setIsTransitioning] = useState(false); + const [key, setKey] = useState(0); + + useEffect(() => { + let controls; + const size = direction === 'horizontal' ? width : height; + const contentSize = size + gap; + const from = reverse ? -contentSize / 2 : 0; + const to = reverse ? 0 : -contentSize / 2; + + if (isTransitioning) { + controls = animate(translation, [translation.get(), to], { + ease: 'linear', + duration: + currentDuration * Math.abs((translation.get() - to) / contentSize), + onComplete: () => { + setIsTransitioning(false); + setKey((prevKey) => prevKey + 1); + }, + }); + } else { + controls = animate(translation, [from, to], { + ease: 'linear', + duration: currentDuration, + repeat: Infinity, + repeatType: 'loop', + repeatDelay: 0, + onRepeat: () => { + translation.set(from); + }, + }); + } + + return controls?.stop; + }, [ + key, + translation, + currentDuration, + width, + height, + gap, + isTransitioning, + direction, + reverse, + ]); + + const hoverProps = durationOnHover + ? { + onHoverStart: () => { + setIsTransitioning(true); + setCurrentDuration(durationOnHover); + }, + onHoverEnd: () => { + setIsTransitioning(true); + setCurrentDuration(duration); + }, + } + : {}; + + return ( +
+ + {children} + {children} + +
+ ); +} diff --git a/src/components/ui/logo-cloud-3.tsx b/src/components/ui/logo-cloud-3.tsx new file mode 100644 index 0000000..8f7b6d0 --- /dev/null +++ b/src/components/ui/logo-cloud-3.tsx @@ -0,0 +1,39 @@ +import { InfiniteSlider } from "@/components/ui/infinite-slider"; +import { cn } from "@/utilities/ui"; + +type Logo = { + src: string; + alt: string; + width?: number; + height?: number; +}; + +type LogoCloudProps = React.ComponentProps<"div"> & { + logos: Logo[]; +}; + +export function LogoCloud({ className, logos, ...props }: LogoCloudProps) { + return ( +
+ + {logos.map((logo) => ( + {logo.alt} + ))} + +
+ ); +} diff --git a/src/components/ui/menu-toggle-icon.tsx b/src/components/ui/menu-toggle-icon.tsx new file mode 100644 index 0000000..97a8b23 --- /dev/null +++ b/src/components/ui/menu-toggle-icon.tsx @@ -0,0 +1,54 @@ +'use client'; +import React from 'react'; +import { cn } from '@/utilities/ui'; + +type MenuToggleProps = React.ComponentProps<'svg'> & { + open: boolean; + duration?: number; +}; + +export function MenuToggleIcon({ + open, + className, + fill = 'none', + stroke = 'currentColor', + strokeWidth = 2.5, + strokeLinecap = 'round', + strokeLinejoin = 'round', + duration = 500, + ...props +}: MenuToggleProps) { + return ( + + + + + ); +} diff --git a/src/components/ui/use-scroll.tsx b/src/components/ui/use-scroll.tsx new file mode 100644 index 0000000..1de7bbd --- /dev/null +++ b/src/components/ui/use-scroll.tsx @@ -0,0 +1,22 @@ +'use client'; +import React from 'react'; + +export function useScroll(threshold: number) { + const [scrolled, setScrolled] = React.useState(false); + + const onScroll = React.useCallback(() => { + setScrolled(window.scrollY > threshold); + }, [threshold]); + + React.useEffect(() => { + window.addEventListener('scroll', onScroll); + return () => window.removeEventListener('scroll', onScroll); + }, [onScroll]); + + // also check on first load + React.useEffect(() => { + onScroll(); + }, [onScroll]); + + return scrolled; +}