Initial commit
This commit is contained in:
224
src/App.tsx
Normal file
224
src/App.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import ContactSplitForm from '@/components/sections/contact/ContactSplitForm';
|
||||
import FeaturesRevealCards from '@/components/sections/features/FeaturesRevealCards';
|
||||
import FooterMinimal from '@/components/sections/footer/FooterMinimal';
|
||||
import HeroSplit from '@/components/sections/hero/HeroSplit';
|
||||
import NavbarCentered from '@/components/ui/NavbarCentered';
|
||||
import PricingHighlightedCards from '@/components/sections/pricing/PricingHighlightedCards';
|
||||
import TestimonialMetricsCards from '@/components/sections/testimonial/TestimonialMetricsCards';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarCentered
|
||||
logo="BrokieAI"
|
||||
navItems={[
|
||||
{
|
||||
name: "Features",
|
||||
id: "features",
|
||||
},
|
||||
{
|
||||
name: "Pricing",
|
||||
id: "pricing",
|
||||
},
|
||||
{
|
||||
name: "Contact",
|
||||
id: "contact",
|
||||
},
|
||||
]}
|
||||
ctaButton={{
|
||||
text: "Get Started",
|
||||
href: "#contact",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="hero" data-section="hero">
|
||||
<HeroSplit
|
||||
tag="Revolutionary AI Opportunity"
|
||||
title="Turn Zero Into Growth With AI"
|
||||
description="Don't let your situation define your outcome. Leverage our AI-driven platform to identify, scale, and secure high-value opportunities designed for everyone."
|
||||
primaryButton={{
|
||||
text: "Start Earning Now",
|
||||
href: "#pricing",
|
||||
}}
|
||||
secondaryButton={{
|
||||
text: "See How It Works",
|
||||
href: "#features",
|
||||
}}
|
||||
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/a-futuristic-software-interface-for-ai-o-1776315718004-16f273d8.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="features" data-section="features">
|
||||
<FeaturesRevealCards
|
||||
tag="Platform Features"
|
||||
title="Built for Maximum Opportunity"
|
||||
description="Our AI continuously scans thousands of opportunities, matching them perfectly with your unique situation to help you build real wealth."
|
||||
items={[
|
||||
{
|
||||
title: "AI-Powered Scanning",
|
||||
description: "Automated scanning of markets, jobs, and digital assets to surface high-yield opportunities before the rest of the world sees them.",
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/an-ai-assistant-icon-hovering-over-finan-1776315726739-8eb18617.png",
|
||||
},
|
||||
{
|
||||
title: "Smart Matching Engine",
|
||||
description: "The system understands your current capabilities and automatically filters opportunities to maximize your success probability.",
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/an-automated-opportunity-search-interfac-1776315739993-8584bf92.png",
|
||||
},
|
||||
{
|
||||
title: "Scalable Growth Path",
|
||||
description: "From beginner to expert, get personalized advice on how to scale your earnings while utilizing advanced AI analytics tools.",
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/scalable-growth-metrics-represented-as-g-1776315749423-fc035a2a.png",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="pricing" data-section="pricing">
|
||||
<PricingHighlightedCards
|
||||
tag="Get Started"
|
||||
title="Plans That Fit Your Journey"
|
||||
description="Pick the tier that matches your current goal and let the AI do the heavy lifting."
|
||||
plans={[
|
||||
{
|
||||
tag: "Starter",
|
||||
price: "$0",
|
||||
description: "Perfect for testing the waters and learning the basics.",
|
||||
features: [
|
||||
"Daily opportunity alerts",
|
||||
"Basic AI guidance",
|
||||
"Access to community hub",
|
||||
],
|
||||
primaryButton: {
|
||||
text: "Get Started",
|
||||
href: "#contact",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "Growth",
|
||||
price: "$49",
|
||||
highlight: "Most Popular",
|
||||
description: "Scale your income with priority access and tools.",
|
||||
features: [
|
||||
"Instant AI alerts",
|
||||
"Advanced opportunity filters",
|
||||
"Priority platform access",
|
||||
"Full analytics suite",
|
||||
],
|
||||
primaryButton: {
|
||||
text: "Choose Growth",
|
||||
href: "#contact",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "Professional",
|
||||
price: "$199",
|
||||
description: "Full access to enterprise-grade AI opportunity matching.",
|
||||
features: [
|
||||
"1-on-1 strategy sessions",
|
||||
"Dedicated account manager",
|
||||
"Custom opportunity routing",
|
||||
"Early access to tools",
|
||||
],
|
||||
primaryButton: {
|
||||
text: "Go Pro",
|
||||
href: "#contact",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="testimonials" data-section="testimonials">
|
||||
<TestimonialMetricsCards
|
||||
tag="Real Stories"
|
||||
title="Built for Real Results"
|
||||
description="Hear from the community members who have successfully pivoted using our AI platform."
|
||||
testimonials={[
|
||||
{
|
||||
name: "Alex R.",
|
||||
role: "Entrepreneur",
|
||||
company: "GrowthClub",
|
||||
rating: 5,
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/professional-portrait-of-a-young-entrepr-1776315759550-f3788fbf.png",
|
||||
},
|
||||
{
|
||||
name: "Sarah K.",
|
||||
role: "Digital Lead",
|
||||
company: "TechScale",
|
||||
rating: 5,
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/professional-portrait-of-a-young-woman-i-1776315770182-71525f7f.png",
|
||||
},
|
||||
{
|
||||
name: "Marcus J.",
|
||||
role: "Developer",
|
||||
company: "CodeFlow",
|
||||
rating: 5,
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/professional-headshot-of-a-software-engi-1776315780728-e811eca5.png",
|
||||
},
|
||||
{
|
||||
name: "Elena V.",
|
||||
role: "Creative",
|
||||
company: "DesignBase",
|
||||
rating: 5,
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/professional-headshot-of-a-young-creativ-1776315789537-79172cca.png",
|
||||
},
|
||||
{
|
||||
name: "Liam T.",
|
||||
role: "Student",
|
||||
company: "UniTech",
|
||||
rating: 5,
|
||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/portrait-of-a-young-student-leveraging-t-1776315801553-922ef252.png",
|
||||
},
|
||||
]}
|
||||
metrics={[
|
||||
{
|
||||
value: "10K+",
|
||||
label: "Opportunities Found",
|
||||
},
|
||||
{
|
||||
value: "5M+",
|
||||
label: "Revenue Generated",
|
||||
},
|
||||
{
|
||||
value: "98%",
|
||||
label: "User Success Rate",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="contact" data-section="contact">
|
||||
<ContactSplitForm
|
||||
tag="Ready to Pivot?"
|
||||
title="Join the Next Generation"
|
||||
description="Sign up today and get your first 3 AI-matched opportunities delivered to your inbox."
|
||||
inputs={[
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
placeholder: "Your Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "Your Email",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
buttonText="Join Now"
|
||||
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AJc0x1D8fz8hQeSgAZ5t4kfi3f/abstract-dark-blue-tech-background-with--1776315810000-2c5bb1af.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterMinimal
|
||||
brand="BrokieAI © 2024"
|
||||
copyright="All rights reserved. Empowering every individual."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
142
src/components/sections/contact/ContactSplitForm.tsx
Normal file
142
src/components/sections/contact/ContactSplitForm.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type InputField = {
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type TextareaField = {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type ContactSplitFormProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputs: InputField[];
|
||||
textarea?: TextareaField;
|
||||
buttonText: string;
|
||||
onSubmit?: (data: Record<string, string>) => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ContactSplitForm = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputs,
|
||||
textarea,
|
||||
buttonText,
|
||||
onSubmit,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: ContactSplitFormProps) => {
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
initial[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
initial[textarea.name] = "";
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (onSubmit) {
|
||||
onSubmit(formData);
|
||||
const reset: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
reset[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
reset[textarea.name] = "";
|
||||
}
|
||||
setFormData(reset);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-5"
|
||||
>
|
||||
<div className="p-5 md:p-10 card rounded">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
tag="h2"
|
||||
className="text-4xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
tag="p"
|
||||
className="text-sm md:text-base leading-tight text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{inputs.map((input) => (
|
||||
<input
|
||||
key={input.name}
|
||||
type={input.type}
|
||||
placeholder={input.placeholder}
|
||||
value={formData[input.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||
required={input.required}
|
||||
aria-label={input.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
|
||||
/>
|
||||
))}
|
||||
|
||||
{textarea && (
|
||||
<textarea
|
||||
placeholder={textarea.placeholder}
|
||||
value={formData[textarea.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||
required={textarea.required}
|
||||
rows={textarea.rows || 5}
|
||||
aria-label={textarea.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer primary-button"
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="h-100 md:h-auto card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitForm;
|
||||
105
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
105
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { motion } from "motion/react";
|
||||
import { Info } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesRevealCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesRevealCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
|
||||
<div className="absolute top-5 left-5 z-20 perspective-[1000px]">
|
||||
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden">
|
||||
<span className="text-sm font-medium text-foreground">{index + 1}</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
|
||||
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
|
||||
<div className="relative flex flex-col gap-1 p-3">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
|
||||
|
||||
<h3 className="text-2xl font-semibold leading-tight text-background transition-colors duration-400 group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCards;
|
||||
55
src/components/sections/footer/FooterMinimal.tsx
Normal file
55
src/components/sections/footer/FooterMinimal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
|
||||
type SocialLink = {
|
||||
icon: LucideIcon;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const SocialLinkItem = ({ icon: Icon, href, onClick }: SocialLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-center size-10 rounded-full primary-button text-primary-cta-text cursor-pointer"
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterMinimal = ({
|
||||
brand,
|
||||
copyright,
|
||||
socialLinks,
|
||||
}: {
|
||||
brand: string;
|
||||
copyright: string;
|
||||
socialLinks?: SocialLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="relative w-full py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto px-10 pb-5 rounded-lg card">
|
||||
<AutoFillText className="font-medium" paddingY="py-5">{brand}</AutoFillText>
|
||||
|
||||
<div className="h-px w-full mb-5 bg-foreground/50" />
|
||||
|
||||
<div className="flex flex-col gap-3 items-center justify-between md:flex-row">
|
||||
<span className="text-base opacity-75">{copyright}</span>
|
||||
{socialLinks && socialLinks.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
{socialLinks.map((link, index) => (
|
||||
<SocialLinkItem key={index} icon={link.icon} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterMinimal;
|
||||
64
src/components/sections/hero/HeroSplit.tsx
Normal file
64
src/components/sections/hero/HeroSplit.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroSplitProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroSplitProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3 md:gap-5">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
tag="p"
|
||||
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||
className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-5 card rounded overflow-hidden"
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplit;
|
||||
105
src/components/sections/pricing/PricingHighlightedCards.tsx
Normal file
105
src/components/sections/pricing/PricingHighlightedCards.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { motion } from "motion/react";
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
highlight?: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
};
|
||||
|
||||
const PricingHighlightedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<GridOrCarousel>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.tag} className="flex flex-col h-full">
|
||||
<div className={cls("px-5 py-2 text-sm", plan.highlight ? "text-center primary-button rounded-t text-primary-cta-text" : "invisible")}>
|
||||
{plan.highlight || "placeholder"}
|
||||
</div>
|
||||
|
||||
<div className={cls("flex flex-col items-center gap-5 p-5 flex-1 card text-center", plan.highlight ? "rounded-t-none rounded-b" : "rounded")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-5xl font-medium">{plan.price}</span>
|
||||
<span className="text-xl font-medium">{plan.tag}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-foreground/20" />
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base text-left">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full mt-auto">
|
||||
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
|
||||
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingHighlightedCards;
|
||||
126
src/components/sections/testimonial/TestimonialMetricsCards.tsx
Normal file
126
src/components/sections/testimonial/TestimonialMetricsCards.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { motion } from "motion/react";
|
||||
import { Star } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
role: string;
|
||||
company: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const TestimonialMetricsCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
testimonials,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
metrics: [Metric, Metric, Metric];
|
||||
}) => {
|
||||
return (
|
||||
<section aria-label="Testimonials section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<GridOrCarousel carouselThreshold={4}>
|
||||
{testimonials.map((testimonial) => (
|
||||
<div key={testimonial.name} className="relative aspect-3/4 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={testimonial.imageSrc} videoSrc={testimonial.videoSrc} />
|
||||
|
||||
<div className="absolute inset-x-5 bottom-5 flex flex-col gap-2 p-5 card rounded backdrop-blur-sm">
|
||||
<div className="flex gap-1 mb-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-2xl font-medium leading-tight">{testimonial.name}</span>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base leading-tight">{testimonial.role}</span>
|
||||
<span className="text-base leading-tight opacity-75">{testimonial.company}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="flex flex-col md:flex-row items-center justify-between w-content-width mx-auto p-8 md:py-16 card rounded"
|
||||
>
|
||||
{metrics.map((metric, index) => (
|
||||
<div key={metric.label} className="flex flex-col md:flex-row items-center w-full md:flex-1">
|
||||
<div className={cls("flex flex-col items-center flex-1 gap-1 text-center md:py-0", index === 0 ? "pb-5" : index === 2 ? "pt-5" : "py-5")}>
|
||||
<span className="text-5xl font-medium">{metric.value}</span>
|
||||
<span className="text-base">{metric.label}</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div className="w-full h-px md:h-20 md:w-px bg-foreground/20" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialMetricsCards;
|
||||
46
src/components/ui/AnimatedBarChart.tsx
Normal file
46
src/components/ui/AnimatedBarChart.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const BARS = [
|
||||
{ height: 100, hoverHeight: 40 },
|
||||
{ height: 84, hoverHeight: 100 },
|
||||
{ height: 62, hoverHeight: 75 },
|
||||
{ height: 90, hoverHeight: 50 },
|
||||
{ height: 70, hoverHeight: 90 },
|
||||
{ height: 50, hoverHeight: 60 },
|
||||
{ height: 75, hoverHeight: 85 },
|
||||
{ height: 80, hoverHeight: 70 },
|
||||
];
|
||||
|
||||
const AnimatedBarChart = () => {
|
||||
const [active, setActive] = useState(2);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setActive((p) => (p + 1) % BARS.length), 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:block h-full w-full"
|
||||
style={{ maskImage: "linear-gradient(to bottom, black 40%, transparent)" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex items-end gap-4 h-full w-full">
|
||||
{BARS.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative w-full rounded bg-background-accent transition-all duration-500"
|
||||
style={{ height: `${isHovered ? bar.hoverHeight : bar.height}%` }}
|
||||
>
|
||||
<div className={cls("absolute inset-0 rounded primary-button transition-opacity duration-500", active === i ? "opacity-100" : "opacity-0")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedBarChart;
|
||||
67
src/components/ui/AutoFillText.tsx
Normal file
67
src/components/ui/AutoFillText.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const AutoFillText = ({
|
||||
children,
|
||||
className = "",
|
||||
paddingY = "py-10",
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
paddingY?: string;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLHeadingElement>(null);
|
||||
const [fontSize, setFontSize] = useState<number | null>(null);
|
||||
|
||||
const hasDescenders = /[gjpqy]/.test(children);
|
||||
const lineHeight = hasDescenders ? 1.2 : 0.8;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
const calculateSize = () => {
|
||||
const containerWidth = container.offsetWidth;
|
||||
if (containerWidth === 0) return;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const styles = getComputedStyle(text);
|
||||
ctx.font = `${styles.fontWeight} 100px ${styles.fontFamily}`;
|
||||
const textWidth = ctx.measureText(children).width;
|
||||
|
||||
if (textWidth > 0) {
|
||||
setFontSize((containerWidth / textWidth) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
calculateSize();
|
||||
|
||||
const observer = new ResizeObserver(calculateSize);
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cls("w-full min-w-0 flex-1", !hasDescenders && paddingY)}>
|
||||
<h2
|
||||
ref={textRef}
|
||||
className={cls(
|
||||
"whitespace-nowrap transition-opacity duration-150",
|
||||
fontSize ? "opacity-100" : "opacity-0",
|
||||
className
|
||||
)}
|
||||
style={{ fontSize: fontSize ? `${fontSize}px` : undefined, lineHeight }}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoFillText;
|
||||
44
src/components/ui/Button.tsx
Normal file
44
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
interface ButtonProps {
|
||||
text: string;
|
||||
variant?: "primary" | "secondary";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
animate?: boolean;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Button = ({ text, variant = "primary", href, onClick, animate = false, delay = 0, className = "" }: ButtonProps) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
const classes = cls(
|
||||
"flex items-center justify-center h-9 px-6 text-sm rounded cursor-pointer",
|
||||
variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text",
|
||||
className
|
||||
);
|
||||
|
||||
const button = href
|
||||
? <a href={href} onClick={handleClick} className={classes}>{text}</a>
|
||||
: <button onClick={handleClick} className={classes}>{text}</button>;
|
||||
|
||||
if (!animate) return button;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.6, delay, ease: "easeOut" }}
|
||||
>
|
||||
{button}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
43
src/components/ui/ChatMarquee.tsx
Normal file
43
src/components/ui/ChatMarquee.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Send } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Exchange = { userMessage: string; aiResponse: string };
|
||||
|
||||
const ChatMarquee = ({ aiIcon: AiIcon, userIcon: UserIcon, exchanges, placeholder }: { aiIcon: LucideIcon; userIcon: LucideIcon; exchanges: Exchange[]; placeholder: string }) => {
|
||||
const messages = exchanges.flatMap((e) => [{ content: e.userMessage, isUser: true }, { content: e.aiResponse, isUser: false }]);
|
||||
const duplicated = [...messages, ...messages];
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col px-4 animate-marquee-vertical">
|
||||
{duplicated.map((msg, i) => (
|
||||
<div key={i} className={cls("flex items-end gap-2 mb-4 shrink-0", msg.isUser ? "flex-row-reverse" : "flex-row")}>
|
||||
{msg.isUser ? (
|
||||
<div className="flex items-center justify-center h-8 w-8 primary-button rounded shrink-0">
|
||||
<UserIcon className="h-3 w-3 text-primary-cta-text" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-8 w-8 card rounded shrink-0">
|
||||
<AiIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cls("max-w-3/4 px-4 py-3 text-sm leading-tight", msg.isUser ? "primary-button rounded-2xl rounded-br-none text-primary-cta-text" : "card rounded-2xl rounded-bl-none")}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 pl-4 card rounded">
|
||||
<p className="flex-1 text-sm text-foreground/75 truncate">{placeholder}</p>
|
||||
<div className="flex items-center justify-center h-7 w-7 primary-button rounded">
|
||||
<Send className="h-3 w-3 text-primary-cta-text" strokeWidth={1.75} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMarquee;
|
||||
47
src/components/ui/ChecklistTimeline.tsx
Normal file
47
src/components/ui/ChecklistTimeline.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Check, Loader } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Item = { label: string; detail: string };
|
||||
|
||||
const DELAYS = [
|
||||
["delay-150", "delay-200", "delay-[250ms]"],
|
||||
["delay-[350ms]", "delay-[400ms]", "delay-[450ms]"],
|
||||
["delay-[550ms]", "delay-[600ms]", "delay-[650ms]"],
|
||||
];
|
||||
|
||||
const ChecklistTimeline = ({ heading, subheading, items, completedLabel }: { heading: string; subheading: string; items: [Item, Item, Item]; completedLabel: string }) => (
|
||||
<div className="group relative flex items-center justify-center h-full w-full overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{[1, 0.8, 0.6].map((s) => <div key={s} className="absolute h-full aspect-square rounded-full border border-background-accent/30" style={{ transform: `scale(${s})` }} />)}
|
||||
</div>
|
||||
<div className="relative flex flex-col gap-3 p-4 max-w-full w-8/10 mask-fade-y">
|
||||
<div className="flex items-center gap-2 p-3 card shadow rounded">
|
||||
<Loader className="h-4 w-4 text-primary transition-transform duration-1000 group-hover:rotate-360" strokeWidth={1.5} />
|
||||
<p className="text-xs truncate">{heading}</p>
|
||||
<p className="text-xs text-foreground/75 ml-auto whitespace-nowrap">{subheading}</p>
|
||||
</div>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2 card shadow rounded">
|
||||
<div className="relative flex items-center justify-center h-6 w-6 card shadow rounded">
|
||||
<div className="absolute h-2 w-2 primary-button rounded transition-opacity duration-300 group-hover:opacity-0" />
|
||||
<div className={cls("absolute inset-0 flex items-center justify-center primary-button rounded opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100", DELAYS[i][0])}>
|
||||
<Check className="h-3 w-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between gap-4 min-w-0">
|
||||
<p className={cls("text-xs truncate opacity-0 transition-opacity duration-300 group-hover:opacity-100", DELAYS[i][1])}>{item.label}</p>
|
||||
<p className={cls("text-xs text-foreground/75 whitespace-nowrap opacity-0 translate-y-1 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0", DELAYS[i][2])}>{item.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="relative flex items-center justify-center p-3 primary-button rounded">
|
||||
<div className="absolute flex gap-2 transition-opacity duration-500 delay-900 group-hover:opacity-0">
|
||||
{[0, 1, 2].map((j) => <div key={j} className="h-2 w-2 rounded bg-primary-cta-text" />)}
|
||||
</div>
|
||||
<p className="text-xs text-primary-cta-text truncate opacity-0 transition-opacity duration-500 delay-900 group-hover:opacity-100">{completedLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ChecklistTimeline;
|
||||
64
src/components/ui/GridOrCarousel.tsx
Normal file
64
src/components/ui/GridOrCarousel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Children, type ReactNode } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useCarouselControls } from "@/hooks/useCarouselControls";
|
||||
|
||||
interface GridOrCarouselProps {
|
||||
children: ReactNode;
|
||||
carouselThreshold?: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
const GridOrCarousel = ({ children, carouselThreshold = 4 }: GridOrCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
|
||||
const { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress } = useCarouselControls(emblaApi);
|
||||
|
||||
const items = Children.toArray(children);
|
||||
const count = items.length;
|
||||
|
||||
if (count <= carouselThreshold) {
|
||||
return (
|
||||
<div className={cls(
|
||||
"grid grid-cols-1 gap-5 mx-auto w-content-width",
|
||||
count === 2 && "md:grid-cols-2",
|
||||
count === 3 && "md:grid-cols-3",
|
||||
count === 4 && "md:grid-cols-4"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<div ref={emblaRef} className="overflow-hidden w-full cursor-grab">
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0 w-carousel-padding" />
|
||||
{items.map((child, i) => (
|
||||
<div key={i} className={cls("shrink-0", carouselThreshold === 2 ? "w-carousel-item-2" : carouselThreshold === 3 ? "w-carousel-item-3" : "w-carousel-item-4")}>{child}</div>
|
||||
))}
|
||||
<div className="shrink-0 w-carousel-padding" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<div className="shrink-0 w-carousel-padding-controls" />
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="relative h-2 w-1/2 card rounded overflow-hidden">
|
||||
<div className="absolute top-0 bottom-0 -left-full w-full primary-button rounded" style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={scrollPrev} disabled={prevDisabled} type="button" aria-label="Previous" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
|
||||
<ChevronLeft className="h-2/5 aspect-square text-secondary-cta-text" />
|
||||
</button>
|
||||
<button onClick={scrollNext} disabled={nextDisabled} type="button" aria-label="Next" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
|
||||
<ChevronRight className="h-2/5 aspect-square text-secondary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-carousel-padding-controls" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridOrCarousel;
|
||||
61
src/components/ui/HoverPattern.tsx
Normal file
61
src/components/ui/HoverPattern.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { motion, useMotionValue, useMotionTemplate } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const randomChars = () => Array.from({ length: 1500 }, () => CHARS[Math.floor(Math.random() * 62)]).join("");
|
||||
|
||||
interface HoverPatternProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HoverPattern = ({ children, className = "" }: HoverPatternProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const [chars, setChars] = useState(randomChars);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && ref.current) {
|
||||
x.set(ref.current.offsetWidth / 2);
|
||||
y.set(ref.current.offsetHeight / 2);
|
||||
}
|
||||
}, [isMobile, x, y]);
|
||||
|
||||
const mask = useMotionTemplate`radial-gradient(${isMobile ? 110 : 250}px at ${x}px ${y}px, white, transparent)`;
|
||||
const base = "absolute inset-0 rounded-[inherit] transition-opacity duration-300";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cls("group/pattern relative", className)}
|
||||
onMouseMove={isMobile ? undefined : (e) => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
x.set(e.clientX - rect.left);
|
||||
y.set(e.clientY - rect.top);
|
||||
setChars(randomChars());
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[inherit]">
|
||||
<div className={cls(base, isMobile ? "opacity-25" : "opacity-0 group-hover/pattern:opacity-25")} style={{ background: "linear-gradient(white, transparent)" }} />
|
||||
<motion.div className={cls(base, "bg-linear-to-r from-accent to-accent/50 backdrop-blur-xl", isMobile ? "opacity-100" : "opacity-0 group-hover/pattern:opacity-100")} style={{ maskImage: mask, WebkitMaskImage: mask }} />
|
||||
<motion.div className={cls(base, "mix-blend-overlay", isMobile ? "opacity-100" : "opacity-0 group-hover/pattern:opacity-100")} style={{ maskImage: mask, WebkitMaskImage: mask }}>
|
||||
<p className="absolute inset-0 h-full whitespace-pre-wrap wrap-break-word font-mono text-xs font-bold text-white">{chars}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverPattern;
|
||||
27
src/components/ui/IconTextMarquee.tsx
Normal file
27
src/components/ui/IconTextMarquee.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const IconTextMarquee = ({ centerIcon: CenterIcon, texts }: { centerIcon: LucideIcon; texts: string[] }) => {
|
||||
const items = [...texts, ...texts];
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full w-full overflow-hidden" style={{ maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)" }}>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-2 w-full opacity-60">
|
||||
{Array.from({ length: 10 }).map((_, row) => (
|
||||
<div key={row} className={cls("flex gap-2", row % 2 === 0 ? "animate-marquee-horizontal" : "animate-marquee-horizontal-reverse")}>
|
||||
{items.map((text, i) => (
|
||||
<div key={i} className="flex items-center justify-center px-4 py-2 card rounded">
|
||||
<p className="text-sm leading-tight">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex items-center justify-center h-16 w-16 primary-button backdrop-blur-sm rounded">
|
||||
<CenterIcon className="h-6 w-6 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconTextMarquee;
|
||||
41
src/components/ui/ImageOrVideo.tsx
Normal file
41
src/components/ui/ImageOrVideo.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ImageOrVideoProps {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ImageOrVideo = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
className = "",
|
||||
}: ImageOrVideoProps) => {
|
||||
if (videoSrc) {
|
||||
return (
|
||||
<video
|
||||
src={videoSrc}
|
||||
aria-label={videoSrc}
|
||||
className={cls("w-full h-full min-h-0 object-cover rounded", className)}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (imageSrc) {
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={imageSrc}
|
||||
className={cls("w-full h-full min-h-0 object-cover rounded", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ImageOrVideo;
|
||||
27
src/components/ui/InfoCardMarquee.tsx
Normal file
27
src/components/ui/InfoCardMarquee.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type Item = { icon: LucideIcon; label: string; value: string };
|
||||
|
||||
const InfoCardMarquee = ({ items }: { items: Item[] }) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col animate-marquee-vertical">
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-4 p-3 mb-4 card rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center h-10 w-10 secondary-button rounded">
|
||||
<item.icon className="h-4 w-4 text-secondary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-base truncate">{item.label}</p>
|
||||
</div>
|
||||
<p className="text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoCardMarquee;
|
||||
76
src/components/ui/LoopCarousel.tsx
Normal file
76
src/components/ui/LoopCarousel.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Children, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import type { EmblaCarouselType } from "embla-carousel";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface LoopCarouselProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const LoopCarousel = ({ children }: LoopCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const items = Children.toArray(children);
|
||||
|
||||
const onSelect = useCallback((api: EmblaCarouselType) => {
|
||||
setSelectedIndex(api.selectedScrollSnap());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
|
||||
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
emblaApi.on("select", onSelect).on("reInit", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("select", onSelect).off("reInit", onSelect);
|
||||
};
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full md:w-content-width mx-auto">
|
||||
<div ref={emblaRef} className="overflow-hidden w-full mask-fade-x">
|
||||
<div className="flex w-full">
|
||||
{items.map((child, index) => (
|
||||
<div key={index} className="shrink-0 w-content-width md:w-[clamp(18rem,50vw,48rem)] mr-3 md:mr-6">
|
||||
<div
|
||||
className={cls(
|
||||
"transition-all duration-500 ease-out",
|
||||
selectedIndex === index ? "opacity-100 scale-100" : "opacity-70 scale-90"
|
||||
)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 left-0 right-0 flex items-center justify-between w-content-width mx-auto pointer-events-none">
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
className="flex items-center justify-center h-8 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<ChevronLeft className="h-2/5 aspect-square text-primary-cta-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
className="flex items-center justify-center h-8 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<ChevronRight className="h-2/5 aspect-square text-primary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoopCarousel;
|
||||
32
src/components/ui/MediaStack.tsx
Normal file
32
src/components/ui/MediaStack.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Item = { imageSrc?: string; videoSrc?: string };
|
||||
|
||||
const MediaStack = ({ items }: { items: [Item, Item, Item] }) => (
|
||||
<div className="group/stack relative flex items-center justify-center h-full w-full rounded select-none card">
|
||||
<div className={cls(
|
||||
"absolute z-1 overflow-hidden p-1 w-3/5 aspect-4/3 rounded primary-button",
|
||||
"translate-x-[12%] -translate-y-[8%] rotate-8 transition-all duration-500",
|
||||
"group-hover/stack:translate-x-[22%] group-hover/stack:-translate-y-[14%] group-hover/stack:rotate-12"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={items[2].imageSrc} videoSrc={items[2].videoSrc} className="h-full rounded" />
|
||||
</div>
|
||||
<div className={cls(
|
||||
"absolute z-2 overflow-hidden p-1 w-3/5 aspect-4/3 rounded primary-button",
|
||||
"-translate-x-[12%] -translate-y-[8%] -rotate-8 transition-all duration-500",
|
||||
"group-hover/stack:-translate-x-[22%] group-hover/stack:-translate-y-[14%] group-hover/stack:-rotate-12"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={items[1].imageSrc} videoSrc={items[1].videoSrc} className="h-full rounded" />
|
||||
</div>
|
||||
<div className={cls(
|
||||
"absolute z-30 overflow-hidden p-1 w-3/5 aspect-4/3 rounded primary-button",
|
||||
"translate-y-[10%] transition-all duration-500",
|
||||
"group-hover/stack:translate-y-[20%]"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={items[0].imageSrc} videoSrc={items[0].videoSrc} className="h-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MediaStack;
|
||||
122
src/components/ui/NavbarCentered.tsx
Normal file
122
src/components/ui/NavbarCentered.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus, ArrowRight } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface NavbarCenteredProps {
|
||||
logo: string;
|
||||
navItems: { name: string; href: string }[];
|
||||
ctaButton: { text: string; href: string };
|
||||
}
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string, onClose?: () => void) => {
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(href.slice(1));
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const NavbarCentered = ({ logo, navItems, ctaButton }: NavbarCenteredProps) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsScrolled(window.scrollY > 50);
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={cls(
|
||||
"fixed z-1000 top-0 left-0 w-full transition-all duration-500 ease-in-out",
|
||||
isScrolled ? "h-15 bg-background/80 backdrop-blur-sm" : "h-20 bg-background/0 backdrop-blur-0"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center justify-between h-full w-content-width mx-auto">
|
||||
<a href="/" className="text-xl font-medium text-foreground">{logo}</a>
|
||||
|
||||
<div className="hidden md:flex absolute left-1/2 items-center gap-6 -translate-x-1/2">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href)}
|
||||
className="text-base text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex md:hidden items-center justify-center shrink-0 h-8 w-8 bg-foreground rounded cursor-pointer"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<Plus
|
||||
className={cls("w-1/2 h-1/2 text-background transition-transform duration-300", menuOpen ? "rotate-45" : "rotate-0")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ y: "-135%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "-135%" }}
|
||||
transition={{ type: "spring", damping: 26, stiffness: 170 }}
|
||||
className="md:hidden fixed z-1000 top-3 left-3 right-3 p-6 card rounded"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-xl text-foreground">Menu</p>
|
||||
<button
|
||||
className="flex items-center justify-center shrink-0 h-8 w-8 bg-foreground rounded cursor-pointer"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<Plus className="w-1/2 h-1/2 text-background rotate-45" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{navItems.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item.href, () => setMenuOpen(false))}
|
||||
className="flex items-center justify-between py-2 text-base font-medium text-foreground"
|
||||
>
|
||||
{item.name}
|
||||
<ArrowRight className="h-4 w-4 text-foreground" strokeWidth={1.5} />
|
||||
</a>
|
||||
{index < navItems.length - 1 && (
|
||||
<div className="h-px bg-linear-to-r from-transparent via-foreground/20 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" className="w-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarCentered;
|
||||
30
src/components/ui/OrbitingIcons.tsx
Normal file
30
src/components/ui/OrbitingIcons.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const OrbitingIcons = ({ centerIcon: CenterIcon, items }: { centerIcon: LucideIcon; items: LucideIcon[] }) => (
|
||||
<div
|
||||
className="relative flex items-center justify-center h-full overflow-hidden"
|
||||
style={{ perspective: "2000px", maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, transparent, black 10%, black 90%, transparent)", maskComposite: "intersect" }}
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-full" style={{ transform: "rotateY(20deg) rotateX(20deg) rotateZ(-20deg)" }}>
|
||||
<div className="absolute h-60 w-60 opacity-85 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute h-80 w-80 opacity-75 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute h-100 w-100 opacity-65 border border-background-accent shadow rounded-full" />
|
||||
<div className="absolute flex items-center justify-center h-40 w-40 border border-background-accent shadow rounded-full">
|
||||
<div className="flex items-center justify-center h-20 w-20 primary-button rounded-full">
|
||||
<CenterIcon className="h-10 w-10 text-primary-cta-text" strokeWidth={1.25} />
|
||||
</div>
|
||||
{items.map((Icon, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute flex items-center justify-center h-10 w-10 rounded shadow card -ml-5 -mt-5"
|
||||
style={{ top: "50%", left: "50%", animation: "orbit 12s linear infinite", "--initial-position": `${(360 / items.length) * i}deg`, "--translate-position": "160px" } as React.CSSProperties}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default OrbitingIcons;
|
||||
60
src/components/ui/TextAnimation.tsx
Normal file
60
src/components/ui/TextAnimation.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
type Variant = "slide-up" | "fade-blur" | "fade";
|
||||
|
||||
interface TextAnimationProps {
|
||||
text: string;
|
||||
variant: Variant;
|
||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
"slide-up": {
|
||||
hidden: { opacity: 0, y: "50%" },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
},
|
||||
"fade-blur": {
|
||||
hidden: { opacity: 0, filter: "blur(10px)" },
|
||||
visible: { opacity: 1, filter: "blur(0px)" },
|
||||
},
|
||||
"fade": {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const EASING: Record<Variant, [number, number, number, number]> = {
|
||||
"slide-up": [0.25, 0.46, 0.45, 0.94],
|
||||
"fade-blur": [0.45, 0, 0.55, 1],
|
||||
"fade": [0.45, 0, 0.55, 1],
|
||||
};
|
||||
|
||||
const TextAnimation = ({ text, variant, tag = "p", className = "" }: TextAnimationProps) => {
|
||||
const Tag = motion[tag] as typeof motion.p;
|
||||
const words = text.split(" ");
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-20%" }}
|
||||
transition={{ staggerChildren: 0.04 }}
|
||||
>
|
||||
{words.map((word, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="inline-block"
|
||||
variants={VARIANTS[variant]}
|
||||
transition={{ duration: 0.6, ease: EASING[variant] }}
|
||||
>
|
||||
{word}
|
||||
{i < words.length - 1 && "\u00A0"}
|
||||
</motion.span>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextAnimation;
|
||||
28
src/components/ui/TiltedStackCards.tsx
Normal file
28
src/components/ui/TiltedStackCards.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type Item = { icon: LucideIcon; title: string; subtitle: string; detail: string };
|
||||
|
||||
const POS = ["-translate-y-14 hover:-translate-y-20", "translate-x-16 hover:-translate-y-4", "translate-x-32 translate-y-16 hover:translate-y-10"];
|
||||
|
||||
const TiltedStackCards = ({ items }: { items: [Item, Item, Item] }) => (
|
||||
<div
|
||||
className="h-full grid place-items-center [grid-template-areas:'stack']"
|
||||
style={{ maskImage: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent), linear-gradient(to right, black, black 80%, transparent)", maskComposite: "intersect" }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={cls("flex flex-col justify-between gap-2 p-6 w-80 h-36 card rounded transition-all duration-500 -skew-y-[8deg] [grid-area:stack] 2xl:w-90", POS[i])}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center h-5 w-5 rounded primary-button">
|
||||
<item.icon className="h-3 w-3 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-base">{item.title}</p>
|
||||
</div>
|
||||
<p className="text-lg whitespace-nowrap">{item.subtitle}</p>
|
||||
<p className="text-base">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TiltedStackCards;
|
||||
37
src/components/ui/Transition.tsx
Normal file
37
src/components/ui/Transition.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface TransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
transitionType?: "full" | "fade";
|
||||
whileInView?: boolean;
|
||||
}
|
||||
|
||||
const Transition = ({
|
||||
children,
|
||||
className = "flex flex-col w-full gap-6",
|
||||
transitionType = "full",
|
||||
whileInView = true,
|
||||
}: TransitionProps) => {
|
||||
const initial = transitionType === "full"
|
||||
? { opacity: 0, y: 20 }
|
||||
: { opacity: 0 };
|
||||
|
||||
const target = transitionType === "full"
|
||||
? { opacity: 1, y: 0 }
|
||||
: { opacity: 1 };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={initial}
|
||||
{...(whileInView ? { whileInView: target, viewport: { once: true, margin: "-15%" } } : { animate: target })}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Transition;
|
||||
51
src/hooks/useButtonClick.ts
Normal file
51
src/hooks/useButtonClick.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
export const useButtonClick = (href?: string, onClick?: () => void) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const scrollToElement = (sectionId: string, delay: number = 100) => {
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (href) {
|
||||
const isExternalLink = /^(https?:\/\/|www\.)/.test(href);
|
||||
|
||||
if (isExternalLink) {
|
||||
window.open(
|
||||
href.startsWith("www.") ? `https://${href}` : href,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
} else if (href.startsWith("/")) {
|
||||
const [path, hash] = href.split("#");
|
||||
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
if (hash) {
|
||||
setTimeout(() => {
|
||||
window.location.hash = hash;
|
||||
scrollToElement(hash, 100);
|
||||
}, 100);
|
||||
}
|
||||
} else if (hash) {
|
||||
window.location.hash = hash;
|
||||
scrollToElement(hash, 50);
|
||||
}
|
||||
} else if (href.startsWith("#")) {
|
||||
scrollToElement(href.slice(1), 50);
|
||||
} else {
|
||||
scrollToElement(href, 50);
|
||||
}
|
||||
}
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return handleClick;
|
||||
};
|
||||
45
src/hooks/useCarouselControls.ts
Normal file
45
src/hooks/useCarouselControls.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { EmblaCarouselType } from "embla-carousel";
|
||||
|
||||
export const useCarouselControls = (emblaApi: EmblaCarouselType | undefined) => {
|
||||
const [prevDisabled, setPrevDisabled] = useState(true);
|
||||
const [nextDisabled, setNextDisabled] = useState(true);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const onSelect = useCallback((api: EmblaCarouselType) => {
|
||||
setPrevDisabled(!api.canScrollPrev());
|
||||
setNextDisabled(!api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback((api: EmblaCarouselType) => {
|
||||
const progress = Math.max(0, Math.min(1, api.scrollProgress()));
|
||||
setScrollProgress(progress * 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
onScroll(emblaApi);
|
||||
|
||||
emblaApi.on("reInit", onSelect).on("select", onSelect);
|
||||
emblaApi.on("reInit", onScroll).on("scroll", onScroll);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("reInit", onSelect).off("select", onSelect);
|
||||
emblaApi.off("reInit", onScroll).off("scroll", onScroll);
|
||||
};
|
||||
}, [emblaApi, onSelect, onScroll]);
|
||||
|
||||
return { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress };
|
||||
};
|
||||
185
src/index.css
Normal file
185
src/index.css
Normal file
@@ -0,0 +1,185 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/masks.css";
|
||||
@import "./styles/animations.css";
|
||||
|
||||
:root {
|
||||
/* @colorThemes/lightTheme/grayBlueAccent */
|
||||
--background: #020617;
|
||||
--card: #0f172a;
|
||||
--foreground: #e2e8f0;
|
||||
--primary-cta: #c4d8f9;
|
||||
--primary-cta-text: #020617;
|
||||
--secondary-cta: #041633;
|
||||
--secondary-cta-text: #e2e8f0;
|
||||
--accent: #2d30f3;
|
||||
--background-accent: #1d4ed8;
|
||||
|
||||
/* @layout/border-radius/rounded */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* @layout/content-width/medium */
|
||||
--width-content-width: clamp(40rem, 80vw, 100rem);
|
||||
|
||||
/* @utilities/masks */
|
||||
--vw-1_5: 1.5vw;
|
||||
--width-x-padding-mask-fade: 5vw;
|
||||
|
||||
/* @layout/carousel */
|
||||
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-item-2: calc(var(--width-content-width) / 2 - var(--vw-1_5) / 2);
|
||||
--width-carousel-item-3: calc(var(--width-content-width) / 3 - var(--vw-1_5) / 3 * 2);
|
||||
--width-carousel-item-4: calc(var(--width-content-width) / 4 - var(--vw-1_5) / 4 * 3);
|
||||
|
||||
/* @typography/text-sizing/medium */
|
||||
--text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
||||
--text-xs: clamp(0.54rem, 0.72vw, 0.72rem);
|
||||
--text-sm: clamp(0.615rem, 0.82vw, 0.82rem);
|
||||
--text-base: clamp(0.69rem, 0.92vw, 0.92rem);
|
||||
--text-lg: clamp(0.75rem, 1vw, 1rem);
|
||||
--text-xl: clamp(0.825rem, 1.1vw, 1.1rem);
|
||||
--text-2xl: clamp(0.975rem, 1.3vw, 1.3rem);
|
||||
--text-3xl: clamp(1.2rem, 1.6vw, 1.6rem);
|
||||
--text-4xl: clamp(1.5rem, 2vw, 2rem);
|
||||
--text-5xl: clamp(2.025rem, 2.75vw, 2.75rem);
|
||||
--text-6xl: clamp(2.475rem, 3.3vw, 3.3rem);
|
||||
--text-7xl: clamp(3rem, 4vw, 4rem);
|
||||
--text-8xl: clamp(3.5rem, 4.5vw, 4.5rem);
|
||||
--text-9xl: clamp(5.25rem, 7vw, 7rem);
|
||||
}
|
||||
|
||||
/* @typography/text-sizing/medium (mobile) */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--text-2xs: 2.5vw;
|
||||
--text-xs: 2.75vw;
|
||||
--text-sm: 3vw;
|
||||
--text-base: 3.25vw;
|
||||
--text-lg: 3.5vw;
|
||||
--text-xl: 4.25vw;
|
||||
--text-2xl: 5vw;
|
||||
--text-3xl: 6vw;
|
||||
--text-4xl: 7vw;
|
||||
--text-5xl: 7.5vw;
|
||||
--text-6xl: 8.5vw;
|
||||
--text-7xl: 10vw;
|
||||
--text-8xl: 12vw;
|
||||
--text-9xl: 14vw;
|
||||
--width-content-width: 80vw;
|
||||
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-item-2: var(--width-content-width);
|
||||
--width-carousel-item-3: var(--width-content-width);
|
||||
--width-carousel-item-4: var(--width-content-width);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-primary-cta: var(--primary-cta);
|
||||
--color-primary-cta-text: var(--primary-cta-text);
|
||||
--color-secondary-cta: var(--secondary-cta);
|
||||
--color-secondary-cta-text: var(--secondary-cta-text);
|
||||
--color-accent: var(--accent);
|
||||
--color-background-accent: var(--background-accent);
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-mono: monospace;
|
||||
|
||||
/* Border Radius */
|
||||
--radius: var(--radius);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
/* Width */
|
||||
--width-content-width: var(--width-content-width);
|
||||
--width-carousel-padding: var(--width-carousel-padding);
|
||||
--width-carousel-padding-controls: var(--width-carousel-padding-controls);
|
||||
--width-carousel-item-2: var(--width-carousel-item-2);
|
||||
--width-carousel-item-3: var(--width-carousel-item-3);
|
||||
--width-carousel-item-4: var(--width-carousel-item-4);
|
||||
|
||||
/* Typography */
|
||||
--text-2xs: var(--text-2xs);
|
||||
--text-xs: var(--text-xs);
|
||||
--text-sm: var(--text-sm);
|
||||
--text-base: var(--text-base);
|
||||
--text-lg: var(--text-lg);
|
||||
--text-xl: var(--text-xl);
|
||||
--text-2xl: var(--text-2xl);
|
||||
--text-3xl: var(--text-3xl);
|
||||
--text-4xl: var(--text-4xl);
|
||||
--text-5xl: var(--text-5xl);
|
||||
--text-6xl: var(--text-6xl);
|
||||
--text-7xl: var(--text-7xl);
|
||||
--text-8xl: var(--text-8xl);
|
||||
--text-9xl: var(--text-9xl);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 1) rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-dm-sans), sans-serif;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
}
|
||||
|
||||
/* WEBILD_CARD_STYLE */
|
||||
/* @cards/gradient-mesh */
|
||||
.card {
|
||||
background:
|
||||
radial-gradient(at 0% 0%, color-mix(in srgb, var(--color-accent) 15%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 100%, color-mix(in srgb, var(--color-accent) 20%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 100%, color-mix(in srgb, var(--color-accent) 12%, transparent) 0px, transparent 50%),
|
||||
var(--color-card);
|
||||
}
|
||||
|
||||
/* WEBILD_PRIMARY_BUTTON */
|
||||
/* @primaryButtons/lifted */
|
||||
.primary-button {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--color-primary-cta) 95%, var(--color-foreground)) 0%, var(--color-primary-cta) 50%, color-mix(in srgb, var(--color-primary-cta) 95%, var(--color-background)) 100%);
|
||||
box-shadow: inset 0 2px 3px 0 color-mix(in srgb, var(--color-foreground) 20%, transparent), inset 0 -2px 3px 0 color-mix(in srgb, var(--color-background) 25%, transparent), 0 2px 4px -1px color-mix(in srgb, var(--color-background) 40%, transparent);
|
||||
}
|
||||
|
||||
/* WEBILD_SECONDARY_BUTTON */
|
||||
/* @secondaryButtons/layered */
|
||||
.secondary-button {
|
||||
background:
|
||||
linear-gradient(color-mix(in srgb, var(--color-accent) 5%, transparent) 0%, transparent 59.26%),
|
||||
linear-gradient(var(--color-secondary-cta), var(--color-secondary-cta)),
|
||||
linear-gradient(var(--color-secondary-cta), var(--color-secondary-cta)),
|
||||
linear-gradient(color-mix(in srgb, var(--color-accent) 5%, transparent) 0%, transparent 59.26%),
|
||||
linear-gradient(color-mix(in srgb, var(--color-secondary-cta) 60%, transparent), color-mix(in srgb, var(--color-secondary-cta) 60%, transparent)),
|
||||
var(--color-secondary-cta);
|
||||
box-shadow:
|
||||
2.10837px 3.16256px 9.48767px color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
border: 1px solid var(--color-secondary-cta);
|
||||
}
|
||||
10
src/lib/utils.ts
Normal file
10
src/lib/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: (string | undefined | null | false)[]) {
|
||||
return inputs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function cls(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
158
src/styles/animations.css
Normal file
158
src/styles/animations.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* @utilities/animations */
|
||||
|
||||
@keyframes pulsate {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 var(--accent);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 20px 10px transparent;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInOpacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInTranslate {
|
||||
from {
|
||||
transform: translateY(0.75vh);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora {
|
||||
from {
|
||||
background-position: 50% 50%, 50% 50%;
|
||||
}
|
||||
to {
|
||||
background-position: 350% 50%, 350% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical-reverse {
|
||||
from {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-horizontal {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-horizontal-reverse {
|
||||
from {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
from {
|
||||
transform: rotate(var(--initial-position, 0deg)) translateX(var(--translate-position, 120px))
|
||||
rotate(calc(-1 * var(--initial-position, 0deg)));
|
||||
}
|
||||
to {
|
||||
transform: rotate(calc(var(--initial-position, 0deg) + 360deg))
|
||||
translateX(var(--translate-position, 120px))
|
||||
rotate(calc(-1 * (var(--initial-position, 0deg) + 360deg)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes map-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
|
||||
.animate-pulsate {
|
||||
animation: pulsate 1.5s infinite;
|
||||
}
|
||||
|
||||
.animation-container {
|
||||
animation: fadeInOpacity 0.8s ease-in-out forwards, fadeInTranslate 0.6s forwards;
|
||||
}
|
||||
|
||||
.animation-container-fade {
|
||||
animation: fadeInOpacity 0.8s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 15s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 10s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-vertical {
|
||||
animation: marquee-vertical 40s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-vertical-reverse {
|
||||
animation: marquee-vertical-reverse 40s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-horizontal {
|
||||
animation: marquee-horizontal 15s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-horizontal-reverse {
|
||||
animation: marquee-horizontal-reverse 15s linear infinite;
|
||||
}
|
||||
61
src/styles/masks.css
Normal file
61
src/styles/masks.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* @utilities/masks */
|
||||
|
||||
.mask-fade-x {
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-padding-x {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black var(--width-x-padding-mask-fade),
|
||||
black calc(100% - var(--width-x-padding-mask-fade)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.mask-fade-bottom {
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-y {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black var(--vw-1_5),
|
||||
black calc(100% - var(--vw-1_5)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.mask-fade-y-medium {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black 20%,
|
||||
black 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.mask-fade-bottom-large {
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-bottom-long {
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-top-long {
|
||||
mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-xy {
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%),
|
||||
linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
|
||||
.mask-fade-top-overlay {
|
||||
mask-image: linear-gradient(to bottom, transparent, black 60%);
|
||||
}
|
||||
3
src/vite-env.d.ts
vendored
Normal file
3
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "*.css";
|
||||
declare module "*.svg";
|
||||
declare module "*.png";
|
||||
Reference in New Issue
Block a user