Initial commit
This commit is contained in:
190
src/app/admin/page.tsx
Normal file
190
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from "@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay";
|
||||
import HeroCarouselLogo from "@/components/sections/hero/heroCarouselLogo/HeroCarouselLogo";
|
||||
import FeatureCardTwentySix from "@/components/sections/feature/FeatureCardTwentySix";
|
||||
import MetricSplitMediaAbout from "@/components/sections/about/MetricSplitMediaAbout";
|
||||
import FooterBaseReveal from "@/components/sections/footer/FooterBaseReveal";
|
||||
import { Briefcase, FileCheck, Shield, Calendar, Users, BarChart3, CheckCircle, Sparkles, HelpCircle, Phone } from "lucide-react";
|
||||
|
||||
export default function AdminPage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Jobs", id: "jobs" },
|
||||
{ name: "Process", id: "process" },
|
||||
{ name: "FAQs", id: "faqs" },
|
||||
{ name: "Contact", id: "contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "#hero" },
|
||||
{ label: "Jobs", href: "#jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "#faqs" },
|
||||
{ label: "Process Guide", href: "#process" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{
|
||||
text: "Login / Apply",
|
||||
href: "/login",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="hero" data-section="hero">
|
||||
<HeroCarouselLogo
|
||||
logoText="AIIMS CAREERS"
|
||||
description="Join India's Premier Medical Institutes. Explore rewarding opportunities in healthcare, research, and administration across AIIMS network."
|
||||
buttons={[
|
||||
{ text: "Explore Jobs", href: "#jobs" },
|
||||
{ text: "Apply Now", href: "/apply" },
|
||||
]}
|
||||
slides={[
|
||||
{
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/analog-landscape-city-with-buildings_23-2149661462.jpg",
|
||||
imageAlt: "AIIMS Medical Professionals",
|
||||
},
|
||||
{
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Recruitment Dashboard",
|
||||
},
|
||||
{
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "Application Verification",
|
||||
},
|
||||
]}
|
||||
autoplayDelay={4000}
|
||||
showDimOverlay={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="features" data-section="features">
|
||||
<FeatureCardTwentySix
|
||||
title="Portal Features & Capabilities"
|
||||
description="Comprehensive recruitment management designed for AIIMS institutes with multi-role support and streamlined workflows."
|
||||
tag="Key Features"
|
||||
tagIcon={Sparkles}
|
||||
tagAnimation="slide-up"
|
||||
buttonAnimation="blur-reveal"
|
||||
features={[
|
||||
{
|
||||
title: "Job Management",
|
||||
description: "Create, publish, and manage job postings across multiple AIIMS institutes with category-wise vacancy allocation.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Job Management Interface",
|
||||
buttonIcon: Briefcase,
|
||||
buttonHref: "#jobs",
|
||||
},
|
||||
{
|
||||
title: "Application Tracking",
|
||||
description: "Real-time tracking of applicant status from submission through verification, screening, and selection stages.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Application Tracking",
|
||||
buttonIcon: FileCheck,
|
||||
buttonHref: "#tracking",
|
||||
},
|
||||
{
|
||||
title: "Document Verification",
|
||||
description: "Centralized document review system for qualifications, certificates, and eligibility verification by authorized offices.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "Document Verification",
|
||||
buttonIcon: Shield,
|
||||
buttonHref: "#verification",
|
||||
},
|
||||
{
|
||||
title: "Exam Management",
|
||||
description: "Schedule exams, generate admit cards, manage exam centers, and upload results in a unified interface.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/closeup-epidemiologist-working-coronavirus-research-laboratory_637285-4732.jpg",
|
||||
imageAlt: "Exam Scheduling",
|
||||
buttonIcon: Calendar,
|
||||
buttonHref: "#exams",
|
||||
},
|
||||
{
|
||||
title: "Role-Based Access",
|
||||
description: "Separate dashboards for Super Admin, Institute Admins, Applicant Office, and Candidates with tailored workflows.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Role-Based Dashboards",
|
||||
buttonIcon: Users,
|
||||
buttonHref: "#roles",
|
||||
},
|
||||
{
|
||||
title: "Analytics & Reports",
|
||||
description: "Comprehensive reporting on applications, hiring progress, and recruitment metrics across AIIMS network.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Analytics Dashboard",
|
||||
buttonIcon: BarChart3,
|
||||
buttonHref: "#reports",
|
||||
},
|
||||
]}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="process" data-section="process">
|
||||
<MetricSplitMediaAbout
|
||||
tag="Recruitment Workflow"
|
||||
tagIcon={CheckCircle}
|
||||
title="Streamlined Recruitment Lifecycle"
|
||||
description="Our portal manages the complete recruitment journey: from job creation and applicant screening through document verification, examination, interviews, and final selection. Each stage is designed for efficiency, transparency, and compliance with AIIMS recruitment standards."
|
||||
metrics={[
|
||||
{ value: "4", title: "Role Types with Tailored Dashboards" },
|
||||
{ value: "100%", title: "Digital Application Processing" },
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg"
|
||||
imageAlt="Recruitment Process Workflow"
|
||||
useInvertedBackground={true}
|
||||
mediaAnimation="slide-up"
|
||||
metricsAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
242
src/app/applicant-office/page.tsx
Normal file
242
src/app/applicant-office/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from "@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay";
|
||||
import ProductCardTwo from "@/components/sections/product/ProductCardTwo";
|
||||
import TestimonialCardThirteen from "@/components/sections/testimonial/TestimonialCardThirteen";
|
||||
import FaqSplitMedia from "@/components/sections/faq/FaqSplitMedia";
|
||||
import FooterBaseReveal from "@/components/sections/footer/FooterBaseReveal";
|
||||
import { Briefcase, Star, HelpCircle } from "lucide-react";
|
||||
|
||||
export default function ApplicantOfficePage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Jobs", id: "jobs" },
|
||||
{ name: "Process", id: "process" },
|
||||
{ name: "FAQs", id: "faqs" },
|
||||
{ name: "Contact", id: "contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "#hero" },
|
||||
{ label: "Jobs", href: "#jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "#faqs" },
|
||||
{ label: "Process Guide", href: "#process" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{
|
||||
text: "Login / Apply",
|
||||
href: "/login",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="jobs" data-section="jobs">
|
||||
<ProductCardTwo
|
||||
title="Current Job Openings"
|
||||
description="Explore vacancies across AIIMS institutes in nursing, technical, administrative, and medical roles."
|
||||
tag="Available Positions"
|
||||
tagIcon={Briefcase}
|
||||
tagAnimation="entrance-slide"
|
||||
products={[
|
||||
{
|
||||
id: "nursing-officer",
|
||||
brand: "AIIMS Delhi",
|
||||
name: "Nursing Officer",
|
||||
price: "Senior Nursing Position",
|
||||
rating: 5,
|
||||
reviewCount: "45+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Nursing Officer Position",
|
||||
},
|
||||
{
|
||||
id: "technician",
|
||||
brand: "AIIMS Bhubaneswar",
|
||||
name: "Senior Technician",
|
||||
price: "Technical Support Role",
|
||||
rating: 5,
|
||||
reviewCount: "32+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Senior Technician Position",
|
||||
},
|
||||
{
|
||||
id: "admin-officer",
|
||||
brand: "AIIMS Hyderabad",
|
||||
name: "Administrative Officer",
|
||||
price: "Administrative Role",
|
||||
rating: 5,
|
||||
reviewCount: "28+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Administrative Officer Position",
|
||||
},
|
||||
{
|
||||
id: "research-specialist",
|
||||
brand: "AIIMS Jodhpur",
|
||||
name: "Research Specialist",
|
||||
price: "Research Position",
|
||||
rating: 5,
|
||||
reviewCount: "36+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Research Specialist Position",
|
||||
},
|
||||
]}
|
||||
gridVariant="three-columns-all-equal-width"
|
||||
animationType="slide-up"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
buttons={[
|
||||
{ text: "View All Jobs", href: "/jobs" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="testimonials" data-section="testimonials">
|
||||
<TestimonialCardThirteen
|
||||
title="Successful Candidates Share Their Experience"
|
||||
description="Join thousands of healthcare professionals who found their dream role through AIIMS Career Portal."
|
||||
tag="Success Stories"
|
||||
tagIcon={Star}
|
||||
tagAnimation="blur-reveal"
|
||||
testimonials={[
|
||||
{
|
||||
id: "1",
|
||||
name: "Dr. Priya Sharma",
|
||||
handle: "@priyasharma_md",
|
||||
testimonial: "The application process was smooth and transparent. The verification team was professional and quick with document checks. I got selected within 3 months!",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/young-female-doctor-wearing-medical-robe-standing-isolated-green-wall-with-copy-space_141793-59294.jpg",
|
||||
imageAlt: "Dr. Priya Sharma",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Rajesh Kumar",
|
||||
handle: "@rajesh_healthcare",
|
||||
testimonial: "Excellent portal design. Very user-friendly interface and clear communication at every stage. This is how government portals should be!",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/portrait-medical-assistant-holding-checkup-documents-help-doctor-with-treatment-medicine-woman-working-as-nurse-with-uniform-tools-looking-camera-cure-patient_482257-38669.jpg",
|
||||
imageAlt: "Rajesh Kumar",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Dr. Anjali Verma",
|
||||
handle: "@anjali_nursing",
|
||||
testimonial: "Outstanding experience from application submission to admit card download. The portal kept me informed at every step of the process.",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/pleased-young-brunette-female-doctor-uniform-with-stethoscope-looks-side_141793-115606.jpg",
|
||||
imageAlt: "Dr. Anjali Verma",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Vikram Singh",
|
||||
handle: "@vikram_technician",
|
||||
testimonial: "Transparent selection process with proper documentation. I appreciated the professional handling of my application and interview scheduling.",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/smiley-doctor-wearing-white-coat-front-view_23-2149844581.jpg",
|
||||
imageAlt: "Vikram Singh",
|
||||
},
|
||||
]}
|
||||
showRating={true}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={true}
|
||||
animationType="scale-rotate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="faqs" data-section="faqs">
|
||||
<FaqSplitMedia
|
||||
title="Frequently Asked Questions"
|
||||
description="Get answers to common questions about AIIMS recruitment, application process, verification, and more."
|
||||
tag="Help & Support"
|
||||
tagIcon={HelpCircle}
|
||||
tagAnimation="entrance-slide"
|
||||
faqs={[
|
||||
{
|
||||
id: "1",
|
||||
title: "Who can apply for AIIMS positions?",
|
||||
content: "Indian citizens with valid qualifications as specified in the job posting can apply. You must meet the educational requirements, experience criteria, and age limits mentioned in the vacancy notice. Candidates from SC/ST/OBC/UR categories can apply as per government guidelines.",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "How do I track my application status?",
|
||||
content: "After registration, log in to your applicant dashboard using your credentials. Your dashboard displays real-time updates on your application status including submission confirmation, document verification stage, shortlist status, exam scheduling, and interview calls. You'll also receive email notifications at each stage.",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "What documents are required during verification?",
|
||||
content: "Generally required documents include Aadhar Card, educational certificates, category certificate (if applicable), experience letters, and character certificate. The exact list varies by position. Detailed document requirements are available in the job posting. Applicant Office will request clarifications if documents are incomplete.",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "How long is the entire recruitment process?",
|
||||
content: "The timeline varies based on the position and number of applicants. Typically, document verification takes 2-4 weeks, followed by exam scheduling and interviews. Most positions are completed within 3-4 months from application deadline. You can track progress through your dashboard.",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Can I modify my application after submission?",
|
||||
content: "Applications cannot be modified after final submission. However, if the Applicant Office requests corrections or additional documents during verification, you can provide them within the stipulated time frame through your dashboard.",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "How will I receive admit card and results?",
|
||||
content: "After your documents are verified and you're shortlisted, admit cards will be available through your dashboard. You can download and print them. Similarly, after exam completion, results will be published in your dashboard and you'll receive email notification.",
|
||||
},
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg"
|
||||
imageAlt="AIIMS Recruitment FAQ Support"
|
||||
mediaAnimation="slide-up"
|
||||
faqsAnimation="blur-reveal"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
mediaPosition="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
201
src/app/applicant-portal/page.tsx
Normal file
201
src/app/applicant-portal/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from "@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay";
|
||||
import MetricSplitMediaAbout from "@/components/sections/about/MetricSplitMediaAbout";
|
||||
import FaqSplitMedia from "@/components/sections/faq/FaqSplitMedia";
|
||||
import ContactFaq from "@/components/sections/contact/ContactFaq";
|
||||
import FooterBaseReveal from "@/components/sections/footer/FooterBaseReveal";
|
||||
import { CheckCircle, Phone, HelpCircle } from "lucide-react";
|
||||
|
||||
export default function ApplicantPortalPage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Jobs", id: "jobs" },
|
||||
{ name: "Process", id: "process" },
|
||||
{ name: "FAQs", id: "faqs" },
|
||||
{ name: "Contact", id: "contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Jobs", href: "/jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "#faqs" },
|
||||
{ label: "Process Guide", href: "#process" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{
|
||||
text: "Login / Apply",
|
||||
href: "/login",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="process" data-section="process">
|
||||
<MetricSplitMediaAbout
|
||||
tag="Recruitment Workflow"
|
||||
tagIcon={CheckCircle}
|
||||
title="Streamlined Recruitment Lifecycle"
|
||||
description="Our portal manages the complete recruitment journey: from job creation and applicant screening through document verification, examination, interviews, and final selection. Each stage is designed for efficiency, transparency, and compliance with AIIMS recruitment standards."
|
||||
metrics={[
|
||||
{
|
||||
value: "4",
|
||||
title: "Role Types with Tailored Dashboards",
|
||||
},
|
||||
{
|
||||
value: "100%",
|
||||
title: "Digital Application Processing",
|
||||
},
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg"
|
||||
imageAlt="Recruitment Process Workflow"
|
||||
useInvertedBackground={true}
|
||||
mediaAnimation="slide-up"
|
||||
metricsAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="faqs" data-section="faqs">
|
||||
<FaqSplitMedia
|
||||
title="Frequently Asked Questions"
|
||||
description="Get answers to common questions about AIIMS recruitment, application process, verification, and more."
|
||||
tag="Help & Support"
|
||||
tagIcon={HelpCircle}
|
||||
faqs={[
|
||||
{
|
||||
id: "1",
|
||||
title: "Who can apply for AIIMS positions?",
|
||||
content:
|
||||
"Indian citizens with valid qualifications as specified in the job posting can apply. You must meet the educational requirements, experience criteria, and age limits mentioned in the vacancy notice. Candidates from SC/ST/OBC/UR categories can apply as per government guidelines.",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "How do I track my application status?",
|
||||
content:
|
||||
"After registration, log in to your applicant dashboard using your credentials. Your dashboard displays real-time updates on your application status including submission confirmation, document verification stage, shortlist status, exam scheduling, and interview calls. You'll also receive email notifications at each stage.",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "What documents are required during verification?",
|
||||
content:
|
||||
"Generally required documents include Aadhar Card, educational certificates, category certificate (if applicable), experience letters, and character certificate. The exact list varies by position. Detailed document requirements are available in the job posting. Applicant Office will request clarifications if documents are incomplete.",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "How long is the entire recruitment process?",
|
||||
content:
|
||||
"The timeline varies based on the position and number of applicants. Typically, document verification takes 2-4 weeks, followed by exam scheduling and interviews. Most positions are completed within 3-4 months from application deadline. You can track progress through your dashboard.",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Can I modify my application after submission?",
|
||||
content:
|
||||
"Applications cannot be modified after final submission. However, if the Applicant Office requests corrections or additional documents during verification, you can provide them within the stipulated time frame through your dashboard.",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "How will I receive admit card and results?",
|
||||
content:
|
||||
"After your documents are verified and you're shortlisted, admit cards will be available through your dashboard. You can download and print them. Similarly, after exam completion, results will be published in your dashboard and you'll receive email notification.",
|
||||
},
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg"
|
||||
imageAlt="AIIMS Recruitment FAQ Support"
|
||||
mediaAnimation="slide-up"
|
||||
faqsAnimation="blur-reveal"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
mediaPosition="left"
|
||||
tagAnimation="entrance-slide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="contact" data-section="contact">
|
||||
<ContactFaq
|
||||
ctaTitle="Get Support"
|
||||
ctaDescription="Contact our recruitment support team for assistance with your application or technical issues."
|
||||
ctaIcon={Phone}
|
||||
ctaButton={{
|
||||
text: "Contact Support",
|
||||
href: "mailto:recruitment@aiims.gov.in",
|
||||
}}
|
||||
faqs={[
|
||||
{
|
||||
id: "1",
|
||||
title: "What should I do if I forget my login password?",
|
||||
content:
|
||||
"Click 'Forgot Password' on the login page. Enter your registered email address and you'll receive password reset instructions. Follow the link in the email to create a new password.",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Is there technical support available?",
|
||||
content:
|
||||
"Yes. Technical support is available during business hours. You can contact the IT helpdesk via email at techsupport@aiims.gov.in or call the support hotline with your query.",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "What if my documents are rejected?",
|
||||
content:
|
||||
"If documents are rejected, the Applicant Office will notify you with specific reasons. You can resubmit corrected or clarified documents within the specified period to proceed with verification.",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Can I apply for multiple positions?",
|
||||
content:
|
||||
"Yes, you can apply for multiple positions as long as you meet the eligibility criteria for each. Submit separate applications for each position you're interested in.",
|
||||
},
|
||||
]}
|
||||
useInvertedBackground={true}
|
||||
animationType="entrance-slide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
371
src/app/apply/page.tsx
Normal file
371
src/app/apply/page.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
|
||||
import FooterBaseReveal from '@/components/sections/footer/FooterBaseReveal/FooterBaseReveal';
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, FileText, CheckCircle, Upload, AlertCircle } from "lucide-react";
|
||||
|
||||
export default function ApplyPage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Jobs", id: "jobs" },
|
||||
{ name: "Process", id: "process" },
|
||||
{ name: "FAQs", id: "faqs" },
|
||||
{ name: "Contact", id: "contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Jobs", href: "/#jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "/#faqs" },
|
||||
{ label: "Process Guide", href: "/#process" },
|
||||
{ label: "Contact", href: "/#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{
|
||||
text: "Login / Apply",
|
||||
href: "/login",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="apply-hero" data-section="apply-hero" className="mx-auto px-4 md:px-6 py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-light text-foreground mb-4">
|
||||
Submit Your Application
|
||||
</h1>
|
||||
<p className="text-lg text-foreground/70 mb-8">
|
||||
Join AIIMS and build a rewarding career in healthcare. Complete your application in minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-12">
|
||||
<div className="bg-card rounded-2xl p-6 border border-accent/20">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-cta/10 mb-4">
|
||||
<FileText className="text-primary-cta" size={24} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-2">Step 1: Personal Info</h3>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Provide your basic details and contact information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl p-6 border border-accent/20">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-cta/10 mb-4">
|
||||
<Upload className="text-primary-cta" size={24} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-2">Step 2: Upload Documents</h3>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Submit required certificates and credentials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl p-6 border border-accent/20">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-cta/10 mb-4">
|
||||
<CheckCircle className="text-primary-cta" size={24} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-2">Step 3: Review & Submit</h3>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Verify details and finalize your application
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="apply-form" data-section="apply-form" className="mx-auto px-4 md:px-6 py-12 md:py-16">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-card rounded-2xl p-8 border border-accent/20">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-8">Application Form</h2>
|
||||
|
||||
<form className="space-y-6">
|
||||
{/* Personal Information Section */}
|
||||
<div className="pb-8 border-b border-accent/10">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-6 flex items-center gap-2">
|
||||
<span className="text-primary-cta">1</span> Personal Information
|
||||
</h3>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Full Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter your full name"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Email Address *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Phone Number *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="10-digit phone number"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Date of Birth *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<select className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground">
|
||||
<option value="">Select Category</option>
|
||||
<option value="UR">UR (Unreserved)</option>
|
||||
<option value="OBC">OBC</option>
|
||||
<option value="SC">SC</option>
|
||||
<option value="ST">ST</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Education Information Section */}
|
||||
<div className="pb-8 border-b border-accent/10">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-6 flex items-center gap-2">
|
||||
<span className="text-primary-cta">2</span> Education Information
|
||||
</h3>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Highest Qualification *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., B.Sc Nursing"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
University/Institution *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name of institution"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Year of Passing *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="e.g., 2023"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Marks/CGPA *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., 75% or 7.5 CGPA"
|
||||
className="w-full px-4 py-3 rounded-lg border border-accent/20 bg-background focus:outline-none focus:ring-2 focus:ring-primary-cta text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Upload Section */}
|
||||
<div className="pb-8 border-b border-accent/10">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-6 flex items-center gap-2">
|
||||
<span className="text-primary-cta">3</span> Document Upload
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Aadhar Card / ID Proof *
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-accent/30 rounded-lg p-6 hover:bg-accent/5 transition">
|
||||
<input type="file" className="w-full" />
|
||||
<p className="text-xs text-foreground/60 mt-2">PDF, JPG, PNG - Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Qualification Certificate *
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-accent/30 rounded-lg p-6 hover:bg-accent/5 transition">
|
||||
<input type="file" className="w-full" />
|
||||
<p className="text-xs text-foreground/60 mt-2">PDF, JPG, PNG - Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Experience Certificate (if applicable)
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-accent/30 rounded-lg p-6 hover:bg-accent/5 transition">
|
||||
<input type="file" className="w-full" />
|
||||
<p className="text-xs text-foreground/60 mt-2">PDF, JPG, PNG - Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Category Certificate (if applicable)
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-accent/30 rounded-lg p-6 hover:bg-accent/5 transition">
|
||||
<input type="file" className="w-full" />
|
||||
<p className="text-xs text-foreground/60 mt-2">PDF, JPG, PNG - Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Important Note */}
|
||||
<div className="bg-accent/10 border border-accent/30 rounded-lg p-4 flex gap-3">
|
||||
<AlertCircle className="text-primary-cta flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">Important</p>
|
||||
<p className="text-sm text-foreground/70">
|
||||
Ensure all documents are clear, legible, and in the specified format. Invalid or incomplete documents may lead to rejection during verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submission */}
|
||||
<div className="flex gap-4 pt-8">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-6 py-3 rounded-full border border-accent/30 text-foreground font-medium hover:bg-accent/10 transition"
|
||||
>
|
||||
Save Draft
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-6 py-3 rounded-full bg-primary-cta text-primary-cta-text font-medium hover:opacity-90 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
Submit Application
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60 text-center">
|
||||
By submitting, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="apply-help" data-section="apply-help" className="mx-auto px-4 md:px-6 py-12 md:py-16 bg-background-accent/20">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold text-foreground mb-4">Need Help?</h2>
|
||||
<p className="text-foreground/70">
|
||||
Check our FAQ or contact our support team for assistance with your application
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Link href="/#faqs" className="bg-card rounded-2xl p-6 border border-accent/20 hover:border-primary-cta transition group">
|
||||
<h3 className="font-semibold text-foreground mb-2 group-hover:text-primary-cta transition">
|
||||
View FAQs
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/60 mb-4">
|
||||
Find answers to common questions about the application process
|
||||
</p>
|
||||
<span className="text-primary-cta font-medium flex items-center gap-2">
|
||||
Go to FAQs <ArrowRight size={16} />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<a href="mailto:recruitment@aiims.gov.in" className="bg-card rounded-2xl p-6 border border-accent/20 hover:border-primary-cta transition group">
|
||||
<h3 className="font-semibold text-foreground mb-2 group-hover:text-primary-cta transition">
|
||||
Contact Support
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/60 mb-4">
|
||||
Reach out to our recruitment support team
|
||||
</p>
|
||||
<span className="text-primary-cta font-medium flex items-center gap-2">
|
||||
Email Support <ArrowRight size={16} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
5
src/app/globals.css
Normal file
5
src/app/globals.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/variables.css";
|
||||
@import "./styles/theme.css";
|
||||
@import "./styles/utilities.css";
|
||||
@import "./styles/base.css";
|
||||
291
src/app/jobs/page.tsx
Normal file
291
src/app/jobs/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from "@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay";
|
||||
import ProductCardTwo from "@/components/sections/product/ProductCardTwo";
|
||||
import FeatureCardTwentySix from "@/components/sections/feature/FeatureCardTwentySix";
|
||||
import TestimonialCardThirteen from "@/components/sections/testimonial/TestimonialCardThirteen";
|
||||
import FooterBaseReveal from "@/components/sections/footer/FooterBaseReveal";
|
||||
import {
|
||||
Briefcase,
|
||||
FileCheck,
|
||||
Shield,
|
||||
Calendar,
|
||||
Users,
|
||||
BarChart3,
|
||||
Star,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function JobsPage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Jobs", id: "jobs" },
|
||||
{ name: "Process", id: "process" },
|
||||
{ name: "FAQs", id: "faqs" },
|
||||
{ name: "Contact", id: "contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Jobs", href: "/jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "#faqs" },
|
||||
{ label: "Process Guide", href: "#process" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{
|
||||
text: "Login / Apply",
|
||||
href: "/login",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="jobs" data-section="jobs">
|
||||
<ProductCardTwo
|
||||
title="Current Job Openings"
|
||||
description="Explore vacancies across AIIMS institutes in nursing, technical, administrative, and medical roles."
|
||||
tag="Available Positions"
|
||||
tagIcon={Briefcase}
|
||||
products={[
|
||||
{
|
||||
id: "nursing-officer",
|
||||
brand: "AIIMS Delhi",
|
||||
name: "Nursing Officer",
|
||||
price: "Senior Nursing Position",
|
||||
rating: 5,
|
||||
reviewCount: "45+ Applications",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Nursing Officer Position",
|
||||
},
|
||||
{
|
||||
id: "technician",
|
||||
brand: "AIIMS Bhubaneswar",
|
||||
name: "Senior Technician",
|
||||
price: "Technical Support Role",
|
||||
rating: 5,
|
||||
reviewCount: "32+ Applications",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Senior Technician Position",
|
||||
},
|
||||
{
|
||||
id: "admin-officer",
|
||||
brand: "AIIMS Hyderabad",
|
||||
name: "Administrative Officer",
|
||||
price: "Administrative Role",
|
||||
rating: 5,
|
||||
reviewCount: "28+ Applications",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Administrative Officer Position",
|
||||
},
|
||||
{
|
||||
id: "research-specialist",
|
||||
brand: "AIIMS Jodhpur",
|
||||
name: "Research Specialist",
|
||||
price: "Research Position",
|
||||
rating: 5,
|
||||
reviewCount: "36+ Applications",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Research Specialist Position",
|
||||
},
|
||||
]}
|
||||
gridVariant="three-columns-all-equal-width"
|
||||
animationType="slide-up"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
buttons={[
|
||||
{
|
||||
text: "View All Jobs",
|
||||
href: "/jobs",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="features" data-section="features">
|
||||
<FeatureCardTwentySix
|
||||
title="Portal Features & Capabilities"
|
||||
description="Comprehensive recruitment management designed for AIIMS institutes with multi-role support and streamlined workflows."
|
||||
tag="Key Features"
|
||||
tagIcon={Sparkles}
|
||||
tagAnimation="slide-up"
|
||||
features={[
|
||||
{
|
||||
title: "Job Management",
|
||||
description:
|
||||
"Create, publish, and manage job postings across multiple AIIMS institutes with category-wise vacancy allocation.",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Job Management Interface",
|
||||
buttonIcon: Briefcase,
|
||||
buttonHref: "#jobs",
|
||||
},
|
||||
{
|
||||
title: "Application Tracking",
|
||||
description:
|
||||
"Real-time tracking of applicant status from submission through verification, screening, and selection stages.",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Application Tracking",
|
||||
buttonIcon: FileCheck,
|
||||
buttonHref: "#tracking",
|
||||
},
|
||||
{
|
||||
title: "Document Verification",
|
||||
description:
|
||||
"Centralized document review system for qualifications, certificates, and eligibility verification by authorized offices.",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "Document Verification",
|
||||
buttonIcon: Shield,
|
||||
buttonHref: "#verification",
|
||||
},
|
||||
{
|
||||
title: "Exam Management",
|
||||
description:
|
||||
"Schedule exams, generate admit cards, manage exam centers, and upload results in a unified interface.",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/closeup-epidemiologist-working-coronavirus-research-laboratory_637285-4732.jpg",
|
||||
imageAlt: "Exam Scheduling",
|
||||
buttonIcon: Calendar,
|
||||
buttonHref: "#exams",
|
||||
},
|
||||
{
|
||||
title: "Role-Based Access",
|
||||
description:
|
||||
"Separate dashboards for Super Admin, Institute Admins, Applicant Office, and Candidates with tailored workflows.",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Role-Based Dashboards",
|
||||
buttonIcon: Users,
|
||||
buttonHref: "#roles",
|
||||
},
|
||||
{
|
||||
title: "Analytics & Reports",
|
||||
description:
|
||||
"Comprehensive reporting on applications, hiring progress, and recruitment metrics across AIIMS network.",
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Analytics Dashboard",
|
||||
buttonIcon: BarChart3,
|
||||
buttonHref: "#reports",
|
||||
},
|
||||
]}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
buttonAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="testimonials" data-section="testimonials">
|
||||
<TestimonialCardThirteen
|
||||
title="Successful Candidates Share Their Experience"
|
||||
description="Join thousands of healthcare professionals who found their dream role through AIIMS Career Portal."
|
||||
tag="Success Stories"
|
||||
tagIcon={Star}
|
||||
testimonials={[
|
||||
{
|
||||
id: "1",
|
||||
name: "Dr. Priya Sharma",
|
||||
handle: "@priyasharma_md",
|
||||
testimonial:
|
||||
"The application process was smooth and transparent. The verification team was professional and quick with document checks. I got selected within 3 months!",
|
||||
rating: 5,
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/young-female-doctor-wearing-medical-robe-standing-isolated-green-wall-with-copy-space_141793-59294.jpg",
|
||||
imageAlt: "Dr. Priya Sharma",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Rajesh Kumar",
|
||||
handle: "@rajesh_healthcare",
|
||||
testimonial:
|
||||
"Excellent portal design. Very user-friendly interface and clear communication at every stage. This is how government portals should be!",
|
||||
rating: 5,
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/portrait-medical-assistant-holding-checkup-documents-help-doctor-with-treatment-medicine-woman-working-as-nurse-with-uniform-tools-looking-camera-cure-patient_482257-38669.jpg",
|
||||
imageAlt: "Rajesh Kumar",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Dr. Anjali Verma",
|
||||
handle: "@anjali_nursing",
|
||||
testimonial:
|
||||
"Outstanding experience from application submission to admit card download. The portal kept me informed at every step of the process.",
|
||||
rating: 5,
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/pleased-young-brunette-female-doctor-uniform-with-stethoscope-looks-side_141793-115606.jpg",
|
||||
imageAlt: "Dr. Anjali Verma",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Vikram Singh",
|
||||
handle: "@vikram_technician",
|
||||
testimonial:
|
||||
"Transparent selection process with proper documentation. I appreciated the professional handling of my application and interview scheduling.",
|
||||
rating: 5,
|
||||
imageSrc:
|
||||
"http://img.b2bpic.net/free-photo/smiley-doctor-wearing-white-coat-front-view_23-2149844581.jpg",
|
||||
imageAlt: "Vikram Singh",
|
||||
},
|
||||
]}
|
||||
showRating={true}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={true}
|
||||
tagAnimation="blur-reveal"
|
||||
animationType="scale-rotate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
1442
src/app/layout.tsx
Normal file
1442
src/app/layout.tsx
Normal file
File diff suppressed because it is too large
Load Diff
406
src/app/page.tsx
Normal file
406
src/app/page.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
|
||||
import HeroCarouselLogo from '@/components/sections/hero/heroCarouselLogo/HeroCarouselLogo';
|
||||
import FeatureCardTwentySix from '@/components/sections/feature/FeatureCardTwentySix';
|
||||
import MetricSplitMediaAbout from '@/components/sections/about/MetricSplitMediaAbout';
|
||||
import ProductCardTwo from '@/components/sections/product/ProductCardTwo';
|
||||
import TestimonialCardThirteen from '@/components/sections/testimonial/TestimonialCardThirteen';
|
||||
import FaqSplitMedia from '@/components/sections/faq/FaqSplitMedia';
|
||||
import ContactFaq from '@/components/sections/contact/ContactFaq';
|
||||
import FooterBaseReveal from '@/components/sections/footer/FooterBaseReveal/FooterBaseReveal';
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { Sparkles, CheckCircle, Briefcase, FileCheck, Shield, Calendar, Users, BarChart3, Star, HelpCircle, Phone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "hero" },
|
||||
{ name: "Jobs", id: "jobs" },
|
||||
{ name: "Process", id: "process" },
|
||||
{ name: "FAQs", id: "faqs" },
|
||||
{ name: "Contact", id: "contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "#hero" },
|
||||
{ label: "Jobs", href: "#jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "#faqs" },
|
||||
{ label: "Process Guide", href: "#process" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{
|
||||
text: "Login / Apply",
|
||||
href: "/login",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="hero" data-section="hero">
|
||||
<HeroCarouselLogo
|
||||
logoText="AIIMS CAREERS"
|
||||
description="Join India's Premier Medical Institutes. Explore rewarding opportunities in healthcare, research, and administration across AIIMS network."
|
||||
buttons={[
|
||||
{
|
||||
text: "Explore Jobs",
|
||||
href: "#jobs",
|
||||
},
|
||||
{
|
||||
text: "Apply Now",
|
||||
href: "/apply",
|
||||
},
|
||||
]}
|
||||
slides={[
|
||||
{
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/analog-landscape-city-with-buildings_23-2149661462.jpg",
|
||||
imageAlt: "AIIMS Medical Professionals",
|
||||
},
|
||||
{
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Recruitment Dashboard",
|
||||
},
|
||||
{
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "Application Verification",
|
||||
},
|
||||
]}
|
||||
autoplayDelay={4000}
|
||||
showDimOverlay={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="features" data-section="features" className="mx-auto px-4 md:px-6">
|
||||
<FeatureCardTwentySix
|
||||
features={[
|
||||
{
|
||||
title: "Job Management",
|
||||
description: "Create, publish, and manage job postings across multiple AIIMS institutes with category-wise vacancy allocation.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Job Management Interface",
|
||||
buttonIcon: Briefcase,
|
||||
buttonHref: "#jobs",
|
||||
},
|
||||
{
|
||||
title: "Application Tracking",
|
||||
description: "Real-time tracking of applicant status from submission through verification, screening, and selection stages.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Application Tracking",
|
||||
buttonIcon: FileCheck,
|
||||
buttonHref: "#tracking",
|
||||
},
|
||||
{
|
||||
title: "Document Verification",
|
||||
description: "Centralized document review system for qualifications, certificates, and eligibility verification by authorized offices.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "Document Verification",
|
||||
buttonIcon: Shield,
|
||||
buttonHref: "#verification",
|
||||
},
|
||||
{
|
||||
title: "Exam Management",
|
||||
description: "Schedule exams, generate admit cards, manage exam centers, and upload results in a unified interface.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/closeup-epidemiologist-working-coronavirus-research-laboratory_637285-4732.jpg",
|
||||
imageAlt: "Exam Scheduling",
|
||||
buttonIcon: Calendar,
|
||||
buttonHref: "#exams",
|
||||
},
|
||||
{
|
||||
title: "Role-Based Access",
|
||||
description: "Separate dashboards for Super Admin, Institute Admins, Applicant Office, and Candidates with tailored workflows.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Role-Based Dashboards",
|
||||
buttonIcon: Users,
|
||||
buttonHref: "#roles",
|
||||
},
|
||||
{
|
||||
title: "Analytics & Reports",
|
||||
description: "Comprehensive reporting on applications, hiring progress, and recruitment metrics across AIIMS network.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Analytics Dashboard",
|
||||
buttonIcon: BarChart3,
|
||||
buttonHref: "#reports",
|
||||
},
|
||||
]}
|
||||
title="Portal Features & Capabilities"
|
||||
description="Comprehensive recruitment management designed for AIIMS institutes with multi-role support and streamlined workflows."
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
tag="Key Features"
|
||||
tagIcon={Sparkles}
|
||||
tagAnimation="slide-up"
|
||||
buttonAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="process" data-section="process" className="mx-auto px-4 md:px-6">
|
||||
<MetricSplitMediaAbout
|
||||
tag="Recruitment Workflow"
|
||||
tagIcon={CheckCircle}
|
||||
title="Streamlined Recruitment Lifecycle"
|
||||
description="Our portal manages the complete recruitment journey: from job creation and applicant screening through document verification, examination, interviews, and final selection. Each stage is designed for efficiency, transparency, and compliance with AIIMS recruitment standards."
|
||||
metrics={[
|
||||
{
|
||||
value: "4",
|
||||
title: "Role Types with Tailored Dashboards",
|
||||
},
|
||||
{
|
||||
value: "100%",
|
||||
title: "Digital Application Processing",
|
||||
},
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg"
|
||||
imageAlt="Recruitment Process Workflow"
|
||||
useInvertedBackground={true}
|
||||
mediaAnimation="slide-up"
|
||||
metricsAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="jobs" data-section="jobs" className="mx-auto px-4 md:px-6">
|
||||
<ProductCardTwo
|
||||
title="Current Job Openings"
|
||||
description="Explore vacancies across AIIMS institutes in nursing, technical, administrative, and medical roles."
|
||||
tag="Available Positions"
|
||||
products={[
|
||||
{
|
||||
id: "nursing-officer",
|
||||
brand: "AIIMS Delhi",
|
||||
name: "Nursing Officer",
|
||||
price: "Senior Nursing Position",
|
||||
rating: 5,
|
||||
reviewCount: "45+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Nursing Officer Position",
|
||||
},
|
||||
{
|
||||
id: "technician",
|
||||
brand: "AIIMS Bhubaneswar",
|
||||
name: "Senior Technician",
|
||||
price: "Technical Support Role",
|
||||
rating: 5,
|
||||
reviewCount: "32+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Senior Technician Position",
|
||||
},
|
||||
{
|
||||
id: "admin-officer",
|
||||
brand: "AIIMS Hyderabad",
|
||||
name: "Administrative Officer",
|
||||
price: "Administrative Role",
|
||||
rating: 5,
|
||||
reviewCount: "28+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Administrative Officer Position",
|
||||
},
|
||||
{
|
||||
id: "research-specialist",
|
||||
brand: "AIIMS Jodhpur",
|
||||
name: "Research Specialist",
|
||||
price: "Research Position",
|
||||
rating: 5,
|
||||
reviewCount: "36+ Applications",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/rear-view-adult-man-searching-new-job-working-writing-his-resume-laptop_662251-2153.jpg",
|
||||
imageAlt: "Research Specialist Position",
|
||||
},
|
||||
]}
|
||||
gridVariant="three-columns-all-equal-width"
|
||||
animationType="slide-up"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
tagIcon={Briefcase}
|
||||
tagAnimation="entrance-slide"
|
||||
buttons={[
|
||||
{
|
||||
text: "View All Jobs",
|
||||
href: "/jobs",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="testimonials" data-section="testimonials" className="mx-auto px-4 md:px-6">
|
||||
<TestimonialCardThirteen
|
||||
title="Successful Candidates Share Their Experience"
|
||||
description="Join thousands of healthcare professionals who found their dream role through AIIMS Career Portal."
|
||||
tag="Success Stories"
|
||||
testimonials={[
|
||||
{
|
||||
id: "1",
|
||||
name: "Dr. Priya Sharma",
|
||||
handle: "@priyasharma_md",
|
||||
testimonial: "The application process was smooth and transparent. The verification team was professional and quick with document checks. I got selected within 3 months!",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/young-female-doctor-wearing-medical-robe-standing-isolated-green-wall-with-copy-space_141793-59294.jpg",
|
||||
imageAlt: "Dr. Priya Sharma",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Rajesh Kumar",
|
||||
handle: "@rajesh_healthcare",
|
||||
testimonial: "Excellent portal design. Very user-friendly interface and clear communication at every stage. This is how government portals should be!",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/portrait-medical-assistant-holding-checkup-documents-help-doctor-with-treatment-medicine-woman-working-as-nurse-with-uniform-tools-looking-camera-cure-patient_482257-38669.jpg",
|
||||
imageAlt: "Rajesh Kumar",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Dr. Anjali Verma",
|
||||
handle: "@anjali_nursing",
|
||||
testimonial: "Outstanding experience from application submission to admit card download. The portal kept me informed at every step of the process.",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/pleased-young-brunette-female-doctor-uniform-with-stethoscope-looks-side_141793-115606.jpg",
|
||||
imageAlt: "Dr. Anjali Verma",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Vikram Singh",
|
||||
handle: "@vikram_technician",
|
||||
testimonial: "Transparent selection process with proper documentation. I appreciated the professional handling of my application and interview scheduling.",
|
||||
rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/smiley-doctor-wearing-white-coat-front-view_23-2149844581.jpg",
|
||||
imageAlt: "Vikram Singh",
|
||||
},
|
||||
]}
|
||||
showRating={true}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={true}
|
||||
tagIcon={Star}
|
||||
tagAnimation="blur-reveal"
|
||||
animationType="scale-rotate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="faqs" data-section="faqs" className="mx-auto px-4 md:px-6">
|
||||
<FaqSplitMedia
|
||||
title="Frequently Asked Questions"
|
||||
description="Get answers to common questions about AIIMS recruitment, application process, verification, and more."
|
||||
tag="Help & Support"
|
||||
faqs={[
|
||||
{
|
||||
id: "1",
|
||||
title: "Who can apply for AIIMS positions?",
|
||||
content: "Indian citizens with valid qualifications as specified in the job posting can apply. You must meet the educational requirements, experience criteria, and age limits mentioned in the vacancy notice. Candidates from SC/ST/OBC/UR categories can apply as per government guidelines.",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "How do I track my application status?",
|
||||
content: "After registration, log in to your applicant dashboard using your credentials. Your dashboard displays real-time updates on your application status including submission confirmation, document verification stage, shortlist status, exam scheduling, and interview calls. You'll also receive email notifications at each stage.",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "What documents are required during verification?",
|
||||
content: "Generally required documents include Aadhar Card, educational certificates, category certificate (if applicable), experience letters, and character certificate. The exact list varies by position. Detailed document requirements are available in the job posting. Applicant Office will request clarifications if documents are incomplete.",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "How long is the entire recruitment process?",
|
||||
content: "The timeline varies based on the position and number of applicants. Typically, document verification takes 2-4 weeks, followed by exam scheduling and interviews. Most positions are completed within 3-4 months from application deadline. You can track progress through your dashboard.",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Can I modify my application after submission?",
|
||||
content: "Applications cannot be modified after final submission. However, if the Applicant Office requests corrections or additional documents during verification, you can provide them within the stipulated time frame through your dashboard.",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "How will I receive admit card and results?",
|
||||
content: "After your documents are verified and you're shortlisted, admit cards will be available through your dashboard. You can download and print them. Similarly, after exam completion, results will be published in your dashboard and you'll receive email notification.",
|
||||
},
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg"
|
||||
imageAlt="AIIMS Recruitment FAQ Support"
|
||||
mediaAnimation="slide-up"
|
||||
faqsAnimation="blur-reveal"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
mediaPosition="left"
|
||||
tagIcon={HelpCircle}
|
||||
tagAnimation="entrance-slide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="contact" data-section="contact" className="mx-auto px-4 md:px-6">
|
||||
<ContactFaq
|
||||
ctaTitle="Get Support"
|
||||
ctaDescription="Contact our recruitment support team for assistance with your application or technical issues."
|
||||
ctaIcon={Phone}
|
||||
ctaButton={{
|
||||
text: "Contact Support",
|
||||
href: "mailto:recruitment@aiims.gov.in",
|
||||
}}
|
||||
faqs={[
|
||||
{
|
||||
id: "1",
|
||||
title: "What should I do if I forget my login password?",
|
||||
content: "Click 'Forgot Password' on the login page. Enter your registered email address and you'll receive password reset instructions. Follow the link in the email to create a new password.",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Is there technical support available?",
|
||||
content: "Yes. Technical support is available during business hours. You can contact the IT helpdesk via email at techsupport@aiims.gov.in or call the support hotline with your query.",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "What if my documents are rejected?",
|
||||
content: "If documents are rejected, the Applicant Office will notify you with specific reasons. You can resubmit corrected or clarified documents within the specified period to proceed with verification.",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Can I apply for multiple positions?",
|
||||
content: "Yes, you can apply for multiple positions as long as you meet the eligibility criteria for each. Submit separate applications for each position you're interested in.",
|
||||
},
|
||||
]}
|
||||
useInvertedBackground={true}
|
||||
animationType="entrance-slide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
28
src/app/styles/base.css
Normal file
28
src/app/styles/base.css
Normal file
@@ -0,0 +1,28 @@
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 1) rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-nunito-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-nunito-sans), sans-serif;
|
||||
}
|
||||
176
src/app/styles/theme.css
Normal file
176
src/app/styles/theme.css
Normal file
@@ -0,0 +1,176 @@
|
||||
@theme inline {
|
||||
--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);
|
||||
|
||||
/* theme border radius */
|
||||
--radius-theme: var(--theme-border-radius);
|
||||
--radius-theme-capped: var(--theme-border-radius-capped);
|
||||
|
||||
/* text */
|
||||
--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);
|
||||
|
||||
/* height */
|
||||
--height-4: var(--height-4);
|
||||
--height-5: var(--height-5);
|
||||
--height-6: var(--height-6);
|
||||
--height-7: var(--height-7);
|
||||
--height-8: var(--height-8);
|
||||
--height-9: var(--height-9);
|
||||
--height-11: var(--height-11);
|
||||
--height-12: var(--height-12);
|
||||
|
||||
--height-10: var(--height-10);
|
||||
--height-30: var(--height-30);
|
||||
--height-90: var(--height-90);
|
||||
--height-100: var(--height-100);
|
||||
--height-110: var(--height-110);
|
||||
--height-120: var(--height-120);
|
||||
--height-130: var(--height-130);
|
||||
--height-140: var(--height-140);
|
||||
--height-150: var(--height-150);
|
||||
|
||||
--height-page-padding: calc(2.25rem+var(--vw-1_5)+var(--vw-1_5));
|
||||
|
||||
/* width */
|
||||
--width-5: var(--width-5);
|
||||
--width-7_5: var(--width-7_5);
|
||||
--width-10: var(--width-10);
|
||||
--width-12_5: var(--width-12_5);
|
||||
--width-15: var(--width-15);
|
||||
--width-17: var(--width-17);
|
||||
--width-17_5: var(--width-17_5);
|
||||
--width-20: var(--width-20);
|
||||
--width-21: var(--width-21);
|
||||
--width-22_5: var(--width-22_5);
|
||||
--width-25: var(--width-25);
|
||||
--width-26: var(--width-26);
|
||||
--width-27_5: var(--width-27_5);
|
||||
--width-30: var(--width-30);
|
||||
--width-32_5: var(--width-32_5);
|
||||
--width-35: var(--width-35);
|
||||
--width-37_5: var(--width-37_5);
|
||||
--width-40: var(--width-40);
|
||||
--width-42_5: var(--width-42_5);
|
||||
--width-45: var(--width-45);
|
||||
--width-47_5: var(--width-47_5);
|
||||
--width-50: var(--width-50);
|
||||
--width-52_5: var(--width-52_5);
|
||||
--width-55: var(--width-55);
|
||||
--width-57_5: var(--width-57_5);
|
||||
--width-60: var(--width-60);
|
||||
--width-62_5: var(--width-62_5);
|
||||
--width-65: var(--width-65);
|
||||
--width-67_5: var(--width-67_5);
|
||||
--width-70: var(--width-70);
|
||||
--width-72_5: var(--width-72_5);
|
||||
--width-75: var(--width-75);
|
||||
--width-77_5: var(--width-77_5);
|
||||
--width-80: var(--width-80);
|
||||
--width-82_5: var(--width-82_5);
|
||||
--width-85: var(--width-85);
|
||||
--width-87_5: var(--width-87_5);
|
||||
--width-90: var(--width-90);
|
||||
--width-92_5: var(--width-92_5);
|
||||
--width-95: var(--width-95);
|
||||
--width-97_5: var(--width-97_5);
|
||||
--width-100: var(--width-100);
|
||||
--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-padding-expanded: var(--width-carousel-padding-expanded);
|
||||
--width-carousel-padding-controls-expanded: var(--width-carousel-padding-controls-expanded);
|
||||
--width-carousel-item-3: var(--width-carousel-item-3);
|
||||
--width-carousel-item-4: var(--width-carousel-item-4);
|
||||
--width-x-padding-mask-fade: var(--width-x-padding-mask-fade);
|
||||
--width-content-width-expanded: var(--width-content-width-expanded);
|
||||
|
||||
/* gap */
|
||||
--spacing-1: var(--vw-0_25);
|
||||
--spacing-2: var(--vw-0_5);
|
||||
--spacing-3: var(--vw-0_75);
|
||||
--spacing-4: var(--vw-1);
|
||||
--spacing-5: var(--vw-1_25);
|
||||
--spacing-6: var(--vw-1_5);
|
||||
--spacing-7: var(--vw-1_75);
|
||||
--spacing-8: var(--vw-2);
|
||||
|
||||
--spacing-x-1: var(--vw-0_25);
|
||||
--spacing-x-2: var(--vw-0_5);
|
||||
--spacing-x-3: var(--vw-0_75);
|
||||
--spacing-x-4: var(--vw-1);
|
||||
--spacing-x-5: var(--vw-1_25);
|
||||
--spacing-x-6: var(--vw-1_5);
|
||||
|
||||
/* border radius */
|
||||
--radius-none: 0;
|
||||
--radius-sm: var(--vw-0_5);
|
||||
--radius: var(--vw-0_75);
|
||||
--radius-md: var(--vw-1);
|
||||
--radius-lg: var(--vw-1_25);
|
||||
--radius-xl: var(--vw-1_75);
|
||||
--radius-full: 999px;
|
||||
|
||||
/* padding */
|
||||
--padding-1: var(--vw-0_25);
|
||||
--padding-2: var(--vw-0_5);
|
||||
--padding-2.5: var(--vw-0_625);
|
||||
--padding-3: var(--vw-0_75);
|
||||
--padding-4: var(--vw-1);
|
||||
--padding-5: var(--vw-1_25);
|
||||
--padding-6: var(--vw-1_5);
|
||||
--padding-7: var(--vw-1_75);
|
||||
--padding-8: var(--vw-2);
|
||||
|
||||
--padding-x-1: var(--vw-0_25);
|
||||
--padding-x-2: var(--vw-0_5);
|
||||
--padding-x-3: var(--vw-0_75);
|
||||
--padding-x-4: var(--vw-1);
|
||||
--padding-x-5: var(--vw-1_25);
|
||||
--padding-x-6: var(--vw-1_5);
|
||||
--padding-x-7: var(--vw-1_75);
|
||||
--padding-x-8: var(--vw-2);
|
||||
|
||||
--padding-hero-page-padding-half: var(--padding-hero-page-padding-half);
|
||||
--padding-hero-page-padding: var(--padding-hero-page-padding);
|
||||
--padding-hero-page-padding-1_5: var(--padding-hero-page-padding-1_5);
|
||||
--padding-hero-page-padding-double: var(--padding-hero-page-padding-double);
|
||||
|
||||
/* margin */
|
||||
--margin-1: var(--vw-0_25);
|
||||
--margin-2: var(--vw-0_5);
|
||||
--margin-3: var(--vw-0_75);
|
||||
--margin-4: var(--vw-1);
|
||||
--margin-5: var(--vw-1_25);
|
||||
--margin-6: var(--vw-1_5);
|
||||
--margin-7: var(--vw-1_75);
|
||||
--margin-8: var(--vw-2);
|
||||
|
||||
--margin-x-1: var(--vw-0_25);
|
||||
--margin-x-2: var(--vw-0_5);
|
||||
--margin-x-3: var(--vw-0_75);
|
||||
--margin-x-4: var(--vw-1);
|
||||
--margin-x-5: var(--vw-1_25);
|
||||
--margin-x-6: var(--vw-1_5);
|
||||
--margin-x-7: var(--vw-1_75);
|
||||
--margin-x-8: var(--vw-2);
|
||||
}
|
||||
228
src/app/styles/utilities.css
Normal file
228
src/app/styles/utilities.css
Normal file
@@ -0,0 +1,228 @@
|
||||
@layer components {}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Card, primary-button, and secondary-button styles are now dynamically injected via ThemeProvider */
|
||||
|
||||
/* .card {
|
||||
@apply backdrop-blur-sm bg-gradient-to-br from-card/80 to-card/40 shadow-sm border border-card;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply bg-gradient-to-b from-primary-cta/83 to-primary-cta;
|
||||
box-shadow:
|
||||
color-mix(in srgb, var(--color-background) 25%, transparent) 0px 1px 1px 0px inset,
|
||||
color-mix(in srgb, var(--color-primary-cta) 15%, transparent) 3px 3px 3px 0px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
@apply backdrop-blur-sm bg-gradient-to-br from-secondary-cta/80 to-secondary-cta shadow-sm border border-secondary-cta;
|
||||
} */
|
||||
|
||||
.tag-card {
|
||||
@apply backdrop-blur-sm bg-gradient-to-br from-card/80 to-card/40 shadow-sm border border-card;
|
||||
}
|
||||
|
||||
.inset-glow-border {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.inset-glow-border::before {
|
||||
content: "";
|
||||
@apply absolute pointer-events-none inset-0 p-[1px];
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
color-mix(in srgb, var(--color-primary-cta) 20%, var(--color-background)) 0%,
|
||||
color-mix(in srgb, var(--color-primary-cta) 40%, var(--color-background)) 27%,
|
||||
color-mix(in srgb, var(--color-primary-cta) 60%, var(--color-foreground)) 62%,
|
||||
color-mix(in srgb, var(--color-primary-cta) 80%, var(--color-foreground)) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.mask-fade-x {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, transparent calc((100vw - var(--width-content-width)) / 4), black calc((100vw - var(--width-content-width)) / 2 + 5vw), black calc(100% - (100vw - var(--width-content-width)) / 2 - 5vw), transparent calc(100% - (100vw - var(--width-content-width)) / 4), transparent 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0%, transparent calc((100vw - var(--width-content-width)) / 4), black calc((100vw - var(--width-content-width)) / 2 + 5vw), black calc(100% - (100vw - var(--width-content-width)) / 2 - 5vw), transparent calc(100% - (100vw - var(--width-content-width)) / 4), transparent 100%);
|
||||
}
|
||||
|
||||
.mask-padding-x {
|
||||
-webkit-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-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 {
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
|
||||
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 {
|
||||
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 {
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-bottom-long {
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-top-long {
|
||||
-webkit-mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
|
||||
mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-xy {
|
||||
-webkit-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-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%);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
}
|
||||
|
||||
/* ANIMATION */
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 15s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee-vertical {
|
||||
animation: marquee-vertical 40s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee-vertical-reverse {
|
||||
from {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee-vertical-reverse {
|
||||
animation: marquee-vertical-reverse 40s linear infinite;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
217
src/app/styles/variables.css
Normal file
217
src/app/styles/variables.css
Normal file
@@ -0,0 +1,217 @@
|
||||
:root {
|
||||
/* Base units */
|
||||
/* --vw is set by ThemeProvider */
|
||||
|
||||
/* --background: #f5f4ef;
|
||||
--card: #dad6cd;
|
||||
--foreground: #2a2928;
|
||||
--primary-cta: #2a2928;
|
||||
--secondary-cta: #ecebea;
|
||||
--accent: #ffffff;
|
||||
--background-accent: #ffffff; */
|
||||
|
||||
--background: #f7f6f7;
|
||||
--card: #ffffff;
|
||||
--foreground: #0c1325;
|
||||
--primary-cta: #0798ff;
|
||||
--primary-cta-text: #f7f6f7;
|
||||
--secondary-cta: #ffffff;
|
||||
--secondary-cta-text: #0c1325;
|
||||
--accent: #93c7ff;
|
||||
--background-accent: #a8cde8;
|
||||
|
||||
/* text sizing - set by ThemeProvider */
|
||||
/* --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); */
|
||||
|
||||
/* Base spacing units */
|
||||
--vw-0_25: calc(var(--vw) * 0.25);
|
||||
--vw-0_5: calc(var(--vw) * 0.5);
|
||||
--vw-0_625: calc(var(--vw) * 0.625);
|
||||
--vw-0_75: calc(var(--vw) * 0.75);
|
||||
--vw-1: calc(var(--vw) * 1);
|
||||
--vw-1_25: calc(var(--vw) * 1.25);
|
||||
--vw-1_5: calc(var(--vw) * 1.5);
|
||||
--vw-1_75: calc(var(--vw) * 1.75);
|
||||
--vw-2: calc(var(--vw) * 2);
|
||||
--vw-2_25: calc(var(--vw) * 2.25);
|
||||
--vw-2_5: calc(var(--vw) * 2.5);
|
||||
--vw-2_75: calc(var(--vw) * 2.75);
|
||||
--vw-3: calc(var(--vw) * 3);
|
||||
|
||||
/* width */
|
||||
--width-5: clamp(4rem, 5vw, 6rem);
|
||||
--width-7_5: clamp(5.625rem, 7.5vw, 7.5rem);
|
||||
--width-10: clamp(7.5rem, 10vw, 10rem);
|
||||
--width-12_5: clamp(9.375rem, 12.5vw, 12.5rem);
|
||||
--width-15: clamp(11.25rem, 15vw, 15rem);
|
||||
--width-17: clamp(12.75rem, 17vw, 17rem);
|
||||
--width-17_5: clamp(13.125rem, 17.5vw, 17.5rem);
|
||||
--width-20: clamp(15rem, 20vw, 20rem);
|
||||
--width-21: clamp(15.75rem, 21vw, 21rem);
|
||||
--width-22_5: clamp(16.875rem, 22.5vw, 22.5rem);
|
||||
--width-25: clamp(18.75rem, 25vw, 25rem);
|
||||
--width-26: clamp(19.5rem, 26vw, 26rem);
|
||||
--width-27_5: clamp(20.625rem, 27.5vw, 27.5rem);
|
||||
--width-30: clamp(22.5rem, 30vw, 30rem);
|
||||
--width-32_5: clamp(24.375rem, 32.5vw, 32.5rem);
|
||||
--width-35: clamp(26.25rem, 35vw, 35rem);
|
||||
--width-37_5: clamp(28.125rem, 37.5vw, 37.5rem);
|
||||
--width-40: clamp(30rem, 40vw, 40rem);
|
||||
--width-42_5: clamp(31.875rem, 42.5vw, 42.5rem);
|
||||
--width-45: clamp(33.75rem, 45vw, 45rem);
|
||||
--width-47_5: clamp(35.625rem, 47.5vw, 47.5rem);
|
||||
--width-50: clamp(37.5rem, 50vw, 50rem);
|
||||
--width-52_5: clamp(39.375rem, 52.5vw, 52.5rem);
|
||||
--width-55: clamp(41.25rem, 55vw, 55rem);
|
||||
--width-57_5: clamp(43.125rem, 57.5vw, 57.5rem);
|
||||
--width-60: clamp(45rem, 60vw, 60rem);
|
||||
--width-62_5: clamp(46.875rem, 62.5vw, 62.5rem);
|
||||
--width-65: clamp(48.75rem, 65vw, 65rem);
|
||||
--width-67_5: clamp(50.625rem, 67.5vw, 67.5rem);
|
||||
--width-70: clamp(52.5rem, 70vw, 70rem);
|
||||
--width-72_5: clamp(54.375rem, 72.5vw, 72.5rem);
|
||||
--width-75: clamp(56.25rem, 75vw, 75rem);
|
||||
--width-77_5: clamp(58.125rem, 77.5vw, 77.5rem);
|
||||
--width-80: clamp(60rem, 80vw, 80rem);
|
||||
--width-82_5: clamp(61.875rem, 82.5vw, 82.5rem);
|
||||
--width-85: clamp(63.75rem, 85vw, 85rem);
|
||||
--width-87_5: clamp(65.625rem, 87.5vw, 87.5rem);
|
||||
--width-90: clamp(67.5rem, 90vw, 90rem);
|
||||
--width-92_5: clamp(69.375rem, 92.5vw, 92.5rem);
|
||||
--width-95: clamp(71.25rem, 95vw, 95rem);
|
||||
--width-97_5: clamp(73.125rem, 97.5vw, 97.5rem);
|
||||
--width-100: clamp(75rem, 100vw, 100rem);
|
||||
/* --width-content-width and --width-content-width-expanded are set by ThemeProvider */
|
||||
--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-padding-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px);
|
||||
--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);
|
||||
--width-x-padding-mask-fade: clamp(1.5rem, 4vw, 4rem);
|
||||
|
||||
--height-4: 1rem;
|
||||
--height-5: 1.25rem;
|
||||
--height-6: 1.5rem;
|
||||
--height-7: 1.75rem;
|
||||
--height-8: 2rem;
|
||||
--height-9: 2.25rem;
|
||||
--height-10: 2.5rem;
|
||||
--height-11: 2.75rem;
|
||||
--height-12: 3rem;
|
||||
--height-30: 7.5rem;
|
||||
--height-90: 22.5rem;
|
||||
--height-100: 25rem;
|
||||
--height-110: 27.5rem;
|
||||
--height-120: 30rem;
|
||||
--height-130: 32.5rem;
|
||||
--height-140: 35rem;
|
||||
--height-150: 37.5rem;
|
||||
|
||||
/* hero page padding */
|
||||
--padding-hero-page-padding-half: calc((var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)) / 2);
|
||||
--padding-hero-page-padding: calc(var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10));
|
||||
--padding-hero-page-padding-1_5: calc(1.5 * (var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)));
|
||||
--padding-hero-page-padding-double: calc(2 * (var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)));
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root {
|
||||
/* --vw and text sizing are set by ThemeProvider */
|
||||
/* --vw: 3vw;
|
||||
|
||||
--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-5: 5vw;
|
||||
--width-7_5: 7.5vw;
|
||||
--width-10: 10vw;
|
||||
--width-12_5: 12.5vw;
|
||||
--width-15: 15vw;
|
||||
--width-17_5: 17.5vw;
|
||||
--width-20: 20vw;
|
||||
--width-22_5: 22.5vw;
|
||||
--width-25: 25vw;
|
||||
--width-27_5: 27.5vw;
|
||||
--width-30: 30vw;
|
||||
--width-32_5: 32.5vw;
|
||||
--width-35: 35vw;
|
||||
--width-37_5: 37.5vw;
|
||||
--width-40: 40vw;
|
||||
--width-42_5: 42.5vw;
|
||||
--width-45: 45vw;
|
||||
--width-47_5: 47.5vw;
|
||||
--width-50: 50vw;
|
||||
--width-52_5: 52.5vw;
|
||||
--width-55: 55vw;
|
||||
--width-57_5: 57.5vw;
|
||||
--width-60: 60vw;
|
||||
--width-62_5: 62.5vw;
|
||||
--width-65: 65vw;
|
||||
--width-67_5: 67.5vw;
|
||||
--width-70: 70vw;
|
||||
--width-72_5: 72.5vw;
|
||||
--width-75: 75vw;
|
||||
--width-77_5: 77.5vw;
|
||||
--width-80: 80vw;
|
||||
--width-82_5: 82.5vw;
|
||||
--width-85: 85vw;
|
||||
--width-87_5: 87.5vw;
|
||||
--width-90: 90vw;
|
||||
--width-92_5: 92.5vw;
|
||||
--width-95: 95vw;
|
||||
--width-97_5: 97.5vw;
|
||||
--width-100: 100vw;
|
||||
/* --width-content-width and --width-content-width-expanded are set by ThemeProvider */
|
||||
--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-padding-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
|
||||
--width-carousel-padding-controls-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px);
|
||||
--width-carousel-item-3: var(--width-content-width);
|
||||
--width-carousel-item-4: var(--width-content-width);
|
||||
--width-x-padding-mask-fade: 10vw;
|
||||
|
||||
--height-4: 3.5vw;
|
||||
--height-5: 4.5vw;
|
||||
--height-6: 5.5vw;
|
||||
--height-7: 6.5vw;
|
||||
--height-8: 7.5vw;
|
||||
--height-9: 8.5vw;
|
||||
--height-10: 9vw;
|
||||
--height-11: 10vw;
|
||||
--height-12: 11vw;
|
||||
--height-30: 25vw;
|
||||
--height-90: 81vw;
|
||||
--height-100: 90vw;
|
||||
--height-110: 99vw;
|
||||
--height-120: 108vw;
|
||||
--height-130: 117vw;
|
||||
--height-140: 126vw;
|
||||
--height-150: 135vw;
|
||||
}
|
||||
}
|
||||
167
src/app/super-admin/page.tsx
Normal file
167
src/app/super-admin/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingOverlay from "@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay";
|
||||
import MetricSplitMediaAbout from "@/components/sections/about/MetricSplitMediaAbout";
|
||||
import FeatureCardTwentySix from "@/components/sections/feature/FeatureCardTwentySix";
|
||||
import FooterBaseReveal from "@/components/sections/footer/FooterBaseReveal";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle, BarChart3, Users, Settings, Shield, Activity } from "lucide-react";
|
||||
|
||||
export default function SuperAdminPage() {
|
||||
const navItems = [
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Jobs", id: "#jobs" },
|
||||
{ name: "Process", id: "#process" },
|
||||
{ name: "FAQs", id: "#faqs" },
|
||||
{ name: "Contact", id: "#contact" },
|
||||
];
|
||||
|
||||
const footerColumns = [
|
||||
{
|
||||
title: "Portal",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Jobs", href: "#jobs" },
|
||||
{ label: "Apply", href: "/apply" },
|
||||
{ label: "Track Status", href: "/applicant-portal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
items: [
|
||||
{ label: "FAQ", href: "#faqs" },
|
||||
{ label: "Process Guide", href: "#process" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
{ label: "Documents Required", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" },
|
||||
{ label: "Sitemap", href: "#" },
|
||||
{ label: "Accessibility", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="expand-hover"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="pill"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="mediumLarge"
|
||||
background="floatingGradient"
|
||||
cardStyle="inset"
|
||||
primaryButtonStyle="flat"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="light"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingOverlay
|
||||
brandName="AIIMS Career"
|
||||
navItems={navItems}
|
||||
button={{ text: "Login / Apply", href: "/login" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="admin-overview" data-section="admin-overview">
|
||||
<MetricSplitMediaAbout
|
||||
tag="Super Admin Dashboard"
|
||||
tagIcon={CheckCircle}
|
||||
title="Complete Recruitment Management System"
|
||||
description="The Super Admin Dashboard provides comprehensive oversight of the entire AIIMS recruitment network. Manage all institutes, monitor recruitment progress across positions, review critical metrics, and maintain system integrity. Full access to configuration, user management, and cross-institute reporting."
|
||||
metrics={[
|
||||
{ value: "10+", title: "AIIMS Institutes Managed" },
|
||||
{ value: "5000+", title: "Annual Applicant Processing" },
|
||||
]}
|
||||
imageSrc="http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg"
|
||||
imageAlt="Admin Dashboard Overview"
|
||||
useInvertedBackground={false}
|
||||
mediaAnimation="slide-up"
|
||||
metricsAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="admin-capabilities" data-section="admin-capabilities">
|
||||
<FeatureCardTwentySix
|
||||
title="Super Admin Capabilities"
|
||||
description="Advanced tools and controls for system-wide management, monitoring, and optimization of the recruitment platform."
|
||||
features={[
|
||||
{
|
||||
title: "Multi-Institute Management",
|
||||
description: "Oversee recruitment operations across all AIIMS institutes with centralized dashboard and independent institute modules. Create, monitor, and manage institute administrators and their permissions.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Multi-Institute Management",
|
||||
buttonIcon: Users,
|
||||
buttonHref: "#institutes",
|
||||
},
|
||||
{
|
||||
title: "System Configuration",
|
||||
description: "Configure global system settings, recruitment workflows, exam schedules, and policies. Define position categories, qualification levels, and verification stages applicable across all institutes.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/closeup-epidemiologist-working-coronavirus-research-laboratory_637285-4732.jpg",
|
||||
imageAlt: "System Configuration",
|
||||
buttonIcon: Settings,
|
||||
buttonHref: "#settings",
|
||||
},
|
||||
{
|
||||
title: "User & Role Management",
|
||||
description: "Create and manage all user accounts: Institute Admins, Applicant Office staff, Screening Committees, and Exam Center Coordinators. Assign roles, permissions, and institute affiliations. Monitor user activity and access logs.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "User Management",
|
||||
buttonIcon: Shield,
|
||||
buttonHref: "#users",
|
||||
},
|
||||
]}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="admin-monitoring" data-section="admin-monitoring">
|
||||
<FeatureCardTwentySix
|
||||
title="Monitoring & Analytics"
|
||||
description="Real-time insights into system performance, recruitment metrics, and operational efficiency across the entire AIIMS network."
|
||||
features={[
|
||||
{
|
||||
title: "Network-Wide Analytics",
|
||||
description: "View aggregated recruitment metrics across all institutes: total applications, completion rates, average processing time, and hiring statistics. Compare performance between institutes and identify bottlenecks.",
|
||||
imageSrc: "http://img.b2bpic.net/free-vector/recruitment-employment-agency-isometric-landing-page-banner_1284-57873.jpg",
|
||||
imageAlt: "Analytics Dashboard",
|
||||
buttonIcon: BarChart3,
|
||||
buttonHref: "#analytics",
|
||||
},
|
||||
{
|
||||
title: "System Health Monitoring",
|
||||
description: "Monitor portal performance, database status, and technical metrics. Track active users, server load, and system uptime. Receive alerts for critical issues and system anomalies.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/closeup-epidemiologist-working-coronavirus-research-laboratory_637285-4732.jpg",
|
||||
imageAlt: "System Monitoring",
|
||||
buttonIcon: Activity,
|
||||
buttonHref: "#monitoring",
|
||||
},
|
||||
{
|
||||
title: "Audit & Compliance Reporting",
|
||||
description: "Generate comprehensive audit logs tracking all user actions and system changes. Create compliance reports for government oversight. Export recruitment data for analysis and archival.",
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/recruiters-browsing-resumes-evaluating-applicants_482257-112024.jpg",
|
||||
imageAlt: "Audit Reports",
|
||||
buttonIcon: Shield,
|
||||
buttonHref: "#reports",
|
||||
},
|
||||
]}
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="footer" data-section="footer">
|
||||
<FooterBaseReveal
|
||||
columns={footerColumns}
|
||||
copyrightText="© 2025 All India Institute of Medical Sciences (AIIMS) | Recruitment Portal"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
146
src/components/Accordion.tsx
Normal file
146
src/components/Accordion.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
|
||||
interface AccordionProps {
|
||||
index: number;
|
||||
isActive?: boolean;
|
||||
onToggle?: (index: number) => void;
|
||||
title: string;
|
||||
content: string;
|
||||
animationType?: "smooth" | "instant";
|
||||
showCard?: boolean;
|
||||
useInvertedBackground?: boolean;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
iconContainerClassName?: string;
|
||||
iconClassName?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const Accordion = ({
|
||||
index,
|
||||
isActive: controlledIsActive,
|
||||
onToggle,
|
||||
title,
|
||||
content,
|
||||
animationType = "smooth",
|
||||
showCard = true,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
titleClassName = "",
|
||||
iconContainerClassName = "",
|
||||
iconClassName = "",
|
||||
contentClassName = "",
|
||||
}: AccordionProps) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState("0px");
|
||||
const [internalIsActive, setInternalIsActive] = useState(false);
|
||||
|
||||
const isActive = controlledIsActive !== undefined ? controlledIsActive : internalIsActive;
|
||||
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = showCard
|
||||
? shouldUseInvertedText(useInvertedBackground, theme.cardStyle)
|
||||
: useInvertedBackground;
|
||||
|
||||
useEffect(() => {
|
||||
if (animationType === "smooth") {
|
||||
setHeight(isActive ? `${contentRef.current?.scrollHeight}px` : "0px");
|
||||
}
|
||||
}, [isActive, animationType]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (controlledIsActive === undefined) {
|
||||
setInternalIsActive(!internalIsActive);
|
||||
}
|
||||
if (onToggle) {
|
||||
onToggle(index);
|
||||
}
|
||||
}, [controlledIsActive, internalIsActive, onToggle, index]);
|
||||
|
||||
const headerContent = (
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<h2
|
||||
className={cls(
|
||||
"text-base md:text-xl font-medium",
|
||||
shouldUseLightText ? "text-background" : "text-foreground",
|
||||
animationType === "instant" && "text-left",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className={cls(
|
||||
"h-8 aspect-square flex items-center justify-center rounded-theme primary-button transition-all duration-300",
|
||||
iconContainerClassName
|
||||
)}
|
||||
>
|
||||
<Plus
|
||||
className={cls(
|
||||
"w-4/10 aspect-square text-primary-cta-text",
|
||||
animationType === "smooth" ? "transition-transform duration-500" : "transition-transform duration-300",
|
||||
isActive && "rotate-45",
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const contentElement = (
|
||||
<div
|
||||
className={cls(
|
||||
"text-base",
|
||||
shouldUseLightText ? "text-background" : "text-foreground",
|
||||
animationType === "smooth" && "pt-2",
|
||||
contentClassName
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (animationType === "instant") {
|
||||
return (
|
||||
<div className={cls(showCard && "card rounded-theme", className)}>
|
||||
<button
|
||||
className={cls("cursor-pointer flex flex-row items-center justify-between w-full transition-all duration-300 group", showCard && "p-4")}
|
||||
onClick={handleClick}
|
||||
aria-expanded={isActive}
|
||||
>
|
||||
{headerContent}
|
||||
</button>
|
||||
{isActive && <div className={cls(showCard && "px-4 pb-4")}>{contentElement}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
showCard ? "card p-4 rounded-theme-capped" : "",
|
||||
"cursor-pointer flex flex-col items-center justify-between transition-all duration-500 group",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
aria-expanded={isActive}
|
||||
>
|
||||
{headerContent}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{ maxHeight: height }}
|
||||
className="overflow-hidden transition-[max-height] duration-500 w-full flex flex-col"
|
||||
>
|
||||
{contentElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Accordion.displayName = "Accordion";
|
||||
|
||||
export default memo(Accordion);
|
||||
22
src/components/ServiceWrapper.tsx
Normal file
22
src/components/ServiceWrapper.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
|
||||
export function ServiceWrapper({ children }: { children: React.ReactNode }) {
|
||||
const websiteId = process.env.NEXT_PUBLIC_WEBSITE_ANALYTICS_ID;
|
||||
|
||||
return (
|
||||
<>
|
||||
{websiteId && (
|
||||
<Script
|
||||
async
|
||||
defer
|
||||
data-website-id={websiteId}
|
||||
src="https://analytics.webild.io/script.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
312
src/components/Textbox.tsx
Normal file
312
src/components/Textbox.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import TextAnimation from "./text/TextAnimation";
|
||||
import Button from "./button/Button";
|
||||
import Tag from "./shared/Tag";
|
||||
import AvatarGroup from "./shared/AvatarGroup";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import type { AnimationType } from "./text/types";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { ButtonConfig, ButtonAnimationType } from "@/types/button";
|
||||
import type { Avatar } from "./shared/AvatarGroup";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
import { useButtonAnimation } from "./hooks/useButtonAnimation";
|
||||
|
||||
type TitleSegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "image"; src: string; alt?: string };
|
||||
|
||||
interface TextBoxProps {
|
||||
title: string;
|
||||
titleSegments?: TitleSegment[];
|
||||
description: string;
|
||||
type?: AnimationType;
|
||||
textboxLayout?: TextboxLayout;
|
||||
useInvertedBackground?: InvertedBackground;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
titleImageWrapperClassName?: string;
|
||||
titleImageClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
duration?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
gradientColors?: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
center?: boolean;
|
||||
tag?: string;
|
||||
tagIcon?: LucideIcon;
|
||||
tagClassName?: string;
|
||||
tagAnimation?: ButtonAnimationType;
|
||||
buttons?: ButtonConfig[];
|
||||
buttonAnimation?: ButtonAnimationType;
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
avatars?: Avatar[];
|
||||
avatarText?: string;
|
||||
avatarGroupClassName?: string;
|
||||
avatarsAbove?: boolean;
|
||||
}
|
||||
|
||||
const TextBox = ({
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
type,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
duration = 1,
|
||||
start = "top 80%",
|
||||
end = "top 20%",
|
||||
gradientColors,
|
||||
children,
|
||||
center = false,
|
||||
tag,
|
||||
tagIcon: TagIcon,
|
||||
tagClassName = "",
|
||||
tagAnimation = "none",
|
||||
buttons,
|
||||
buttonAnimation = "none",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
avatars,
|
||||
avatarText,
|
||||
avatarGroupClassName = "",
|
||||
avatarsAbove = false,
|
||||
}: TextBoxProps) => {
|
||||
const theme = useTheme();
|
||||
const { containerRef: tagContainerRef } = useButtonAnimation({
|
||||
animationType: tagAnimation
|
||||
});
|
||||
const { containerRef: buttonContainerRef } = useButtonAnimation({
|
||||
animationType: buttonAnimation
|
||||
});
|
||||
|
||||
// Shared tag component
|
||||
const tagElement = tag ? (
|
||||
<div ref={tagContainerRef}>
|
||||
<Tag
|
||||
text={tag}
|
||||
icon={TagIcon}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={cls(textboxLayout === "default" && "mb-3", tagClassName)}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Shared title component
|
||||
const titleElement = useMemo(() => (
|
||||
<TextAnimation
|
||||
type={type || theme.defaultTextAnimation}
|
||||
text={title}
|
||||
variant="trigger"
|
||||
as="h2"
|
||||
className={cls(
|
||||
textboxLayout === "split" || textboxLayout === "split-actions" || textboxLayout === "split-description" ? "text-7xl font-medium text-balance" : "text-6xl font-medium",
|
||||
center && textboxLayout === "default" && "text-center",
|
||||
useInvertedBackground && "text-background",
|
||||
titleClassName
|
||||
)}
|
||||
duration={duration}
|
||||
start={start}
|
||||
end={end}
|
||||
gradientColors={gradientColors}
|
||||
/>
|
||||
), [type, theme.defaultTextAnimation, title, textboxLayout, center, useInvertedBackground, titleClassName, duration, start, end, gradientColors]);
|
||||
|
||||
// Inline image title component (used when textboxLayout === "inline-image")
|
||||
const inlineImageTitleElement = useMemo(() => titleSegments && titleSegments.length > 0 ? (
|
||||
<h2
|
||||
className={cls(
|
||||
"text-4xl md:text-5xl font-medium text-center leading-[1.15] text-balance",
|
||||
useInvertedBackground && "text-background",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{titleSegments.map((segment, index) => {
|
||||
const imageIndex = titleSegments
|
||||
.slice(0, index + 1)
|
||||
.filter(s => s.type === "image").length - 1;
|
||||
|
||||
const element = segment.type === "text" ? (
|
||||
<span key={index}>{segment.content}</span>
|
||||
) : (
|
||||
<span
|
||||
key={index}
|
||||
className={cls(
|
||||
"inline-block relative primary-button -mt-[0.2em] h-[1.1em] w-auto aspect-square align-middle mx-1 p-0.5 rounded-theme",
|
||||
imageIndex % 2 === 0 ? "-rotate-12" : "rotate-12",
|
||||
titleImageWrapperClassName
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={segment.src}
|
||||
alt={segment.alt || ""}
|
||||
width={24}
|
||||
height={24}
|
||||
className={cls(
|
||||
"absolute inset-0 m-auto h-full w-full rounded-theme",
|
||||
titleImageClassName
|
||||
)}
|
||||
unoptimized={segment.src.startsWith("http") || segment.src.startsWith("//")}
|
||||
aria-hidden={!segment.alt || segment.alt === ""}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span key={index}>
|
||||
{index > 0 && " "}
|
||||
{element}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</h2>
|
||||
) : null, [titleSegments, useInvertedBackground, titleClassName, titleImageWrapperClassName, titleImageClassName]);
|
||||
|
||||
// Shared description component
|
||||
const descriptionElement = useMemo(() => (
|
||||
<TextAnimation
|
||||
type={type || theme.defaultTextAnimation}
|
||||
text={description}
|
||||
variant="words-trigger"
|
||||
as="p"
|
||||
className={cls(
|
||||
"text-lg leading-[1.2]",
|
||||
center && textboxLayout === "default" && "text-center",
|
||||
(textboxLayout === "split" || textboxLayout === "split-description") && "text-balance",
|
||||
useInvertedBackground && "text-background",
|
||||
descriptionClassName
|
||||
)}
|
||||
duration={duration}
|
||||
start={start}
|
||||
end={end}
|
||||
gradientColors={gradientColors}
|
||||
/>
|
||||
), [type, theme.defaultTextAnimation, description, center, textboxLayout, useInvertedBackground, descriptionClassName, duration, start, end, gradientColors]);
|
||||
|
||||
// Shared avatars component
|
||||
const avatarsElement = useMemo(() => avatars && avatars.length > 0 ? (
|
||||
<AvatarGroup
|
||||
avatars={avatars}
|
||||
text={avatarText}
|
||||
className={cls(
|
||||
textboxLayout === "default" && !avatarsAbove && "mt-3",
|
||||
textboxLayout === "default" && avatarsAbove && "mb-3",
|
||||
center && textboxLayout === "default" && "justify-center",
|
||||
avatarGroupClassName
|
||||
)}
|
||||
/>
|
||||
) : null, [avatars, avatarText, textboxLayout, center, avatarGroupClassName, avatarsAbove]);
|
||||
|
||||
// Shared buttons/children component
|
||||
const actionsElement = buttons && buttons.length > 0 ? (
|
||||
<div
|
||||
ref={buttonContainerRef}
|
||||
className={cls(
|
||||
"flex flex-wrap gap-4 max-md:justify-center",
|
||||
textboxLayout === "default" && "w-full mt-3",
|
||||
(textboxLayout === "split" || textboxLayout === "split-actions") && "w-fit",
|
||||
center && textboxLayout === "default" && "justify-center",
|
||||
buttonContainerClassName
|
||||
)}
|
||||
>
|
||||
{/* Limit to 2 buttons for optimal layout */}
|
||||
{buttons.slice(0, 2).map((button, index) => (
|
||||
<Button key={`${button.text}-${index}`} {...getButtonProps(button, index, theme.defaultButtonVariant, buttonClassName, buttonTextClassName)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
// Split layout
|
||||
if (textboxLayout === "split") {
|
||||
return (
|
||||
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
|
||||
<div className="w-full md:w-6/10 flex flex-col gap-3">
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
{descriptionElement}
|
||||
</div>
|
||||
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
|
||||
{actionsElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split actions layout - tag and buttons required, no description
|
||||
if (textboxLayout === "split-actions") {
|
||||
return (
|
||||
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
|
||||
<div className="w-full md:w-6/10 flex flex-col gap-3">
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
</div>
|
||||
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
|
||||
{actionsElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split description layout - tag + title left, description only right (no buttons)
|
||||
if (textboxLayout === "split-description") {
|
||||
return (
|
||||
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
|
||||
<div className="w-full md:w-6/10 flex flex-col gap-3">
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
</div>
|
||||
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
|
||||
{descriptionElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline image layout - centered heading with inline images and optional buttons
|
||||
if (textboxLayout === "inline-image") {
|
||||
return (
|
||||
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
|
||||
{tagElement}
|
||||
{inlineImageTitleElement || titleElement}
|
||||
{descriptionElement}
|
||||
{actionsElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default layout
|
||||
return (
|
||||
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
|
||||
{avatarsAbove && avatarsElement}
|
||||
{tagElement}
|
||||
{titleElement}
|
||||
{descriptionElement}
|
||||
{actionsElement}
|
||||
{!avatarsAbove && avatarsElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TextBox.displayName = "TextBox";
|
||||
|
||||
export default memo(TextBox);
|
||||
44
src/components/background/AnimatedAuroraBackground.tsx
Normal file
44
src/components/background/AnimatedAuroraBackground.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AnimatedAuroraBackgroundProps {
|
||||
className?: string;
|
||||
showRadialGradient?: boolean;
|
||||
/**
|
||||
* Inverts the aurora colors for better visibility.
|
||||
* Use `true` for light backgrounds (makes aurora darker/inverted)
|
||||
* Use `false` for dark backgrounds (keeps aurora colors vibrant)
|
||||
*/
|
||||
invertColors: boolean;
|
||||
}
|
||||
|
||||
const AnimatedAuroraBackground = ({
|
||||
className,
|
||||
showRadialGradient = true,
|
||||
invertColors,
|
||||
}: AnimatedAuroraBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 -z-10 bg-background",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden opacity-30">
|
||||
<div
|
||||
className={cls(
|
||||
"[--base-gradient:repeating-linear-gradient(100deg,var(--background)_0%,var(--background)_7%,transparent_10%,transparent_12%,var(--background)_16%)] [--aurora:repeating-linear-gradient(100deg,var(--color-primary-cta)_10%,var(--color-accent)_15%,var(--color-secondary-cta)_20%,var(--color-accent)_25%,var(--color-primary-cta)_30%)] [background-image:var(--base-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] filter blur-[10px] after:content-[''] after:absolute after:inset-0 after:[background-image:var(--base-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[animation:aurora_60s_linear_infinite] after:[background-attachment:fixed] after:mix-blend-difference pointer-events-none absolute -inset-[10px] opacity-30 will-change-transform",
|
||||
invertColors && "invert",
|
||||
showRadialGradient && "[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]"
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AnimatedAuroraBackground.displayName = "AnimatedAuroraBackground";
|
||||
|
||||
export default memo(AnimatedAuroraBackground);
|
||||
112
src/components/background/AnimatedGridBackground.tsx
Normal file
112
src/components/background/AnimatedGridBackground.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AnimatedGridBackgroundProps {
|
||||
className?: string;
|
||||
squareSize?: number;
|
||||
numSquares?: number;
|
||||
maxOpacity?: number;
|
||||
}
|
||||
|
||||
const AnimatedGridBackground = ({
|
||||
className = "",
|
||||
squareSize = 100,
|
||||
numSquares = 50,
|
||||
maxOpacity = 0.15,
|
||||
}: AnimatedGridBackgroundProps) => {
|
||||
const id = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [squares, setSquares] = useState<Array<{ id: number; pos: [number, number] }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dimensions.width && dimensions.height) {
|
||||
const cols = Math.ceil(dimensions.width / squareSize);
|
||||
const rows = Math.ceil(dimensions.height / squareSize);
|
||||
|
||||
const newSquares = Array.from({ length: numSquares }, (_, i) => ({
|
||||
id: i,
|
||||
pos: [
|
||||
Math.floor(Math.random() * cols),
|
||||
Math.floor(Math.random() * rows),
|
||||
] as [number, number],
|
||||
}));
|
||||
|
||||
setSquares(newSquares);
|
||||
}
|
||||
}, [dimensions, squareSize, numSquares]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls(
|
||||
"absolute inset-0 z-0 pointer-events-none select-none overflow-hidden inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
mask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
WebkitMask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
} as React.CSSProperties}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={`grid-${id}`}
|
||||
width={squareSize}
|
||||
height={squareSize}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d={`M ${squareSize} 0 L 0 0 0 ${squareSize}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-background-accent/50"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#grid-${id})`} />
|
||||
{squares.map(({ id, pos: [x, y] }) => (
|
||||
<motion.rect
|
||||
key={id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: [0, maxOpacity, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 2 + 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
x={x * squareSize}
|
||||
y={y * squareSize}
|
||||
width={squareSize}
|
||||
height={squareSize}
|
||||
fill="var(--color-background-accent)"
|
||||
strokeWidth="0"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AnimatedGridBackground.displayName = "AnimatedGridBackground";
|
||||
|
||||
export default memo(AnimatedGridBackground);
|
||||
32
src/components/background/AuroraBackground.tsx
Normal file
32
src/components/background/AuroraBackground.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AuroraBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AuroraBackground = ({
|
||||
className = "",
|
||||
}: AuroraBackgroundProps) => {
|
||||
return (
|
||||
<div className={cls("fixed inset-0 z-0 w-full h-full bg-background", className)}>
|
||||
<div className="absolute top-0 left-0 w-full h-full z-10 backdrop-blur-3xl" ></div>
|
||||
{/* top center */}
|
||||
<div className="absolute top-0 left-1/2 -translate-y-1/2 -translate-x-[120%] w-[9vw] h-[110vh] bg-background-accent/15 -rotate-[52.5deg] rounded-[100%]" />
|
||||
{/* top right */}
|
||||
<div className="absolute top-[-20vh] right-[2.5vw] -translate-x-[0%] w-[12.5vw] h-[100vh] bg-background-accent/15 -rotate-[60deg] rounded-[100%]" />
|
||||
{/* center left */}
|
||||
<div className="absolute top-[-20vh] left-[2vw] -translate-x-[0%] w-[15vw] h-[150vh] bg-background-accent/20 -rotate-[45deg] rounded-[100%]" />
|
||||
{/* top left */}
|
||||
<div className="absolute top-[-30vh] left-0 -translate-x-[0%] w-[10vw] h-[70vh] bg-background-accent/15 -rotate-[45deg] rounded-[100%]" />
|
||||
{/* bottom center */}
|
||||
<div className="absolute bottom-[-40vh] left-0 -translate-x-[0%] w-[120vw] h-[50vh] bg-background-accent/10 -rotate-[20deg] rounded-[100%]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AuroraBackground.displayName = "AuroraBackground";
|
||||
|
||||
export default memo(AuroraBackground);
|
||||
58
src/components/background/BlurBottomBackground.tsx
Normal file
58
src/components/background/BlurBottomBackground.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useCallback } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
const MASK_GRADIENT = "linear-gradient(to bottom, transparent, black 60%)";
|
||||
const BOTTOM_THRESHOLD = 50;
|
||||
const TOP_THRESHOLD = 50;
|
||||
|
||||
interface BlurBottomBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BlurBottomBackground = ({
|
||||
className = ""
|
||||
}: BlurBottomBackgroundProps) => {
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(true);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
|
||||
|
||||
setIsAtTop(scrollTop <= TOP_THRESHOLD);
|
||||
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed pointer-events-none backdrop-blur-xl w-full h-30 left-0 bottom-0 z-[500] transition-opacity duration-500 ease-out",
|
||||
isAtTop || isAtBottom ? "opacity-0" : "opacity-100",
|
||||
className
|
||||
)}
|
||||
style={{ maskImage: MASK_GRADIENT }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
BlurBottomBackground.displayName = "BlurBottomBackground";
|
||||
|
||||
export default memo(BlurBottomBackground);
|
||||
74
src/components/background/CanvasRevealBackground.tsx
Normal file
74
src/components/background/CanvasRevealBackground.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import CanvasRevealEffect from './CanvasRevealEffect';
|
||||
|
||||
interface CanvasRevealBackgroundProps {
|
||||
className?: string;
|
||||
animationSpeed?: number;
|
||||
dotSize?: number;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const hexToRgb = (hex: string): number[] => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
|
||||
: [0, 255, 255];
|
||||
};
|
||||
|
||||
const CanvasRevealBackground = ({
|
||||
className = "",
|
||||
animationSpeed = 5,
|
||||
dotSize = 3,
|
||||
height = "30%",
|
||||
}: CanvasRevealBackgroundProps) => {
|
||||
const [colors, setColors] = useState<number[][]>([[0, 255, 255]]);
|
||||
|
||||
useEffect(() => {
|
||||
const primaryCta = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-background-accent')
|
||||
.trim();
|
||||
|
||||
if (primaryCta) {
|
||||
setColors([hexToRgb(primaryCta)]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 w-full"
|
||||
style={{
|
||||
height: height,
|
||||
mask: `
|
||||
radial-gradient(ellipse 60% 120% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 10%, rgb(0, 0, 0) 25%, rgb(0, 0, 0) 75%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0) 100%)
|
||||
`,
|
||||
maskComposite: 'intersect',
|
||||
WebkitMask: `
|
||||
radial-gradient(ellipse 60% 120% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 10%, rgb(0, 0, 0) 25%, rgb(0, 0, 0) 75%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0) 100%)
|
||||
`,
|
||||
WebkitMaskComposite: 'source-in',
|
||||
}}
|
||||
>
|
||||
<CanvasRevealEffect
|
||||
animationSpeed={animationSpeed}
|
||||
colors={colors}
|
||||
dotSize={dotSize}
|
||||
showGradient={false}
|
||||
containerClassName="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CanvasRevealBackground.displayName = 'CanvasRevealBackground';
|
||||
|
||||
export default memo(CanvasRevealBackground);
|
||||
304
src/components/background/CanvasRevealEffect.tsx
Normal file
304
src/components/background/CanvasRevealEffect.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { useMemo, useRef, useCallback, memo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface CanvasRevealEffectProps {
|
||||
animationSpeed?: number;
|
||||
opacities?: number[];
|
||||
colors?: number[][];
|
||||
containerClassName?: string;
|
||||
dotSize?: number;
|
||||
showGradient?: boolean;
|
||||
}
|
||||
|
||||
const CanvasRevealEffect = ({
|
||||
animationSpeed = 0.4,
|
||||
opacities = [0.2, 0.2, 0.2, 0.4, 0.4, 0.4, 0.7, 0.6, 0.6, 0.9],
|
||||
colors = [[0, 255, 255]],
|
||||
containerClassName = "",
|
||||
dotSize = 3,
|
||||
showGradient = true,
|
||||
}: CanvasRevealEffectProps) => {
|
||||
return (
|
||||
<div className={cls('h-full relative bg-white w-full', containerClassName)}>
|
||||
<div className="h-full w-full">
|
||||
<DotMatrix
|
||||
colors={colors}
|
||||
dotSize={dotSize}
|
||||
opacities={opacities}
|
||||
shader={`
|
||||
float animation_speed_factor = ${animationSpeed.toFixed(1)};
|
||||
float intro_offset = distance(u_resolution / 2.0 / u_total_size, st2) * 0.01 + (random(st2) * 0.15);
|
||||
opacity *= step(intro_offset, u_time * animation_speed_factor);
|
||||
opacity *= clamp((1.0 - step(intro_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
|
||||
`}
|
||||
center={['x', 'y']}
|
||||
/>
|
||||
</div>
|
||||
{showGradient && (
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-950 to-[84%]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DotMatrixProps {
|
||||
colors?: number[][];
|
||||
opacities?: number[];
|
||||
totalSize?: number;
|
||||
dotSize?: number;
|
||||
shader?: string;
|
||||
center?: ('x' | 'y')[];
|
||||
}
|
||||
|
||||
const DotMatrix = ({
|
||||
colors = [[0, 0, 0]],
|
||||
opacities = [0.04, 0.04, 0.04, 0.04, 0.04, 0.08, 0.08, 0.08, 0.08, 0.14],
|
||||
totalSize = 4,
|
||||
dotSize = 2,
|
||||
shader = '',
|
||||
center = ['x', 'y'],
|
||||
}: DotMatrixProps) => {
|
||||
const uniforms = useMemo(() => {
|
||||
let colorsArray = [
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
];
|
||||
if (colors.length === 2) {
|
||||
colorsArray = [
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[1],
|
||||
colors[1],
|
||||
colors[1],
|
||||
];
|
||||
} else if (colors.length === 3) {
|
||||
colorsArray = [
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[1],
|
||||
colors[1],
|
||||
colors[2],
|
||||
colors[2],
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
u_colors: {
|
||||
value: colorsArray.map((color) => [
|
||||
color[0] / 255,
|
||||
color[1] / 255,
|
||||
color[2] / 255,
|
||||
]),
|
||||
type: 'uniform3fv',
|
||||
},
|
||||
u_opacities: {
|
||||
value: opacities,
|
||||
type: 'uniform1fv',
|
||||
},
|
||||
u_total_size: {
|
||||
value: totalSize,
|
||||
type: 'uniform1f',
|
||||
},
|
||||
u_dot_size: {
|
||||
value: dotSize,
|
||||
type: 'uniform1f',
|
||||
},
|
||||
};
|
||||
}, [colors, opacities, totalSize, dotSize]);
|
||||
|
||||
return (
|
||||
<Shader
|
||||
source={`
|
||||
precision mediump float;
|
||||
in vec2 fragCoord;
|
||||
|
||||
uniform float u_time;
|
||||
uniform float u_opacities[10];
|
||||
uniform vec3 u_colors[6];
|
||||
uniform float u_total_size;
|
||||
uniform float u_dot_size;
|
||||
uniform vec2 u_resolution;
|
||||
out vec4 fragColor;
|
||||
float PHI = 1.61803398874989484820459;
|
||||
float random(vec2 xy) {
|
||||
return fract(tan(distance(xy * PHI, xy) * 0.5) * xy.x);
|
||||
}
|
||||
float map(float value, float min1, float max1, float min2, float max2) {
|
||||
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
|
||||
}
|
||||
void main() {
|
||||
vec2 st = fragCoord.xy;
|
||||
${
|
||||
center.includes('x')
|
||||
? 'st.x -= abs(floor((mod(u_resolution.x, u_total_size) - u_dot_size) * 0.5));'
|
||||
: ''
|
||||
}
|
||||
${
|
||||
center.includes('y')
|
||||
? 'st.y -= abs(floor((mod(u_resolution.y, u_total_size) - u_dot_size) * 0.5));'
|
||||
: ''
|
||||
}
|
||||
float opacity = step(0.0, st.x);
|
||||
opacity *= step(0.0, st.y);
|
||||
|
||||
vec2 st2 = vec2(int(st.x / u_total_size), int(st.y / u_total_size));
|
||||
|
||||
float frequency = 5.0;
|
||||
float show_offset = random(st2);
|
||||
float rand = random(st2 * floor((u_time / frequency) + show_offset + frequency) + 1.0);
|
||||
opacity *= u_opacities[int(rand * 10.0)];
|
||||
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.x / u_total_size));
|
||||
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.y / u_total_size));
|
||||
|
||||
vec3 color = u_colors[int(show_offset * 6.0)];
|
||||
|
||||
${shader}
|
||||
|
||||
fragColor = vec4(color, opacity);
|
||||
fragColor.rgb *= fragColor.a;
|
||||
}`}
|
||||
uniforms={uniforms}
|
||||
maxFps={60}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type Uniforms = {
|
||||
[key: string]: {
|
||||
value: number[] | number[][] | number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ShaderMaterial = ({
|
||||
source,
|
||||
uniforms,
|
||||
maxFps = 60,
|
||||
}: {
|
||||
source: string;
|
||||
maxFps?: number;
|
||||
uniforms: Uniforms;
|
||||
}) => {
|
||||
const { size } = useThree();
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
let lastFrameTime = 0;
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const timestamp = clock.getElapsedTime();
|
||||
if (timestamp - lastFrameTime < 1 / maxFps) {
|
||||
return;
|
||||
}
|
||||
lastFrameTime = timestamp;
|
||||
|
||||
const material = ref.current.material as THREE.ShaderMaterial;
|
||||
const timeLocation = material.uniforms.u_time;
|
||||
timeLocation.value = timestamp;
|
||||
});
|
||||
|
||||
const getUniforms = useCallback(() => {
|
||||
const preparedUniforms: Record<string, { value: unknown; type?: string }> = {};
|
||||
|
||||
for (const uniformName in uniforms) {
|
||||
const uniform = uniforms[uniformName] as { type: string; value: number | number[] | number[][] };
|
||||
|
||||
switch (uniform.type) {
|
||||
case 'uniform1f':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1f' };
|
||||
break;
|
||||
case 'uniform3f':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector3().fromArray(uniform.value as number[]),
|
||||
type: '3f',
|
||||
};
|
||||
break;
|
||||
case 'uniform1fv':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1fv' };
|
||||
break;
|
||||
case 'uniform3fv':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: (uniform.value as number[][]).map((v: number[]) =>
|
||||
new THREE.Vector3().fromArray(v)
|
||||
),
|
||||
type: '3fv',
|
||||
};
|
||||
break;
|
||||
case 'uniform2f':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector2().fromArray(uniform.value as number[]),
|
||||
type: '2f',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
console.error(`Invalid uniform type for '${uniformName}'.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
preparedUniforms['u_time'] = { value: 0, type: '1f' };
|
||||
preparedUniforms['u_resolution'] = {
|
||||
value: new THREE.Vector2(size.width * 2, size.height * 2),
|
||||
};
|
||||
return preparedUniforms;
|
||||
}, [uniforms, size.width, size.height]);
|
||||
|
||||
const material = useMemo(() => {
|
||||
const materialObject = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
precision mediump float;
|
||||
in vec2 coordinates;
|
||||
uniform vec2 u_resolution;
|
||||
out vec2 fragCoord;
|
||||
void main(){
|
||||
float x = position.x;
|
||||
float y = position.y;
|
||||
gl_Position = vec4(x, y, 0.0, 1.0);
|
||||
fragCoord = (position.xy + vec2(1.0)) * 0.5 * u_resolution;
|
||||
fragCoord.y = u_resolution.y - fragCoord.y;
|
||||
}
|
||||
`,
|
||||
fragmentShader: source,
|
||||
uniforms: getUniforms(),
|
||||
glslVersion: THREE.GLSL3,
|
||||
blending: THREE.CustomBlending,
|
||||
blendSrc: THREE.SrcAlphaFactor,
|
||||
blendDst: THREE.OneFactor,
|
||||
});
|
||||
|
||||
return materialObject;
|
||||
}, [source, getUniforms]);
|
||||
|
||||
return (
|
||||
<mesh ref={ref as React.Ref<THREE.Mesh>}>
|
||||
<planeGeometry args={[2, 2]} />
|
||||
<primitive object={material} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
interface ShaderProps {
|
||||
source: string;
|
||||
uniforms: Uniforms;
|
||||
maxFps?: number;
|
||||
}
|
||||
|
||||
const Shader = ({ source, uniforms, maxFps = 60 }: ShaderProps) => {
|
||||
return (
|
||||
<Canvas className="absolute inset-0 h-full w-full">
|
||||
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
CanvasRevealEffect.displayName = 'CanvasRevealEffect';
|
||||
|
||||
export default memo(CanvasRevealEffect);
|
||||
56
src/components/background/CardPattern.tsx
Normal file
56
src/components/background/CardPattern.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { motion, useMotionTemplate, type MotionValue } from "framer-motion";
|
||||
|
||||
const GRADIENT_SIZE = 250;
|
||||
|
||||
interface CardPatternProps {
|
||||
mouseX: MotionValue<number>;
|
||||
mouseY: MotionValue<number>;
|
||||
randomString: string;
|
||||
isActive?: boolean;
|
||||
gradientClassName?: string;
|
||||
}
|
||||
|
||||
function CardPatternComponent({
|
||||
mouseX,
|
||||
mouseY,
|
||||
randomString,
|
||||
isActive = false,
|
||||
gradientClassName,
|
||||
}: CardPatternProps) {
|
||||
const maskImage = useMotionTemplate`radial-gradient(${GRADIENT_SIZE}px at ${mouseX}px ${mouseY}px, white, transparent)`;
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
maskImage,
|
||||
WebkitMaskImage: maskImage,
|
||||
}),
|
||||
[maskImage]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<div
|
||||
className={`absolute inset-0 rounded-theme-capped [mask-image:linear-gradient(white,transparent)] ${isActive ? "opacity-50" : "group-hover/primary-button:opacity-50"}`}
|
||||
/>
|
||||
<motion.div
|
||||
className={`absolute inset-0 rounded-theme-capped ${gradientClassName} backdrop-blur-xl transition duration-500 ${isActive ? "opacity-100" : "opacity-0 group-hover/primary-button:opacity-100"}`}
|
||||
style={style}
|
||||
/>
|
||||
<motion.div
|
||||
className={`absolute inset-0 rounded-theme-capped mix-blend-overlay ${isActive ? "opacity-100" : "opacity-0 group-hover/primary-button:opacity-100"}`}
|
||||
style={style}
|
||||
>
|
||||
<p className="absolute inset-x-0 text-xs h-full break-words whitespace-pre-wrap text-white font-mono font-bold transition duration-500">
|
||||
{randomString}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CardPatternComponent.displayName = "CardPattern";
|
||||
|
||||
export const CardPattern = memo(CardPatternComponent);
|
||||
103
src/components/background/CellWaveBackground.tsx
Normal file
103
src/components/background/CellWaveBackground.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface CellWaveBackgroundProps {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
cellColor?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CellWaveBackground = ({
|
||||
columns = 5,
|
||||
rows = 24,
|
||||
cellColor = 'var(--color-background-accent)',
|
||||
duration = 0.25,
|
||||
delay = 1.25,
|
||||
className = ''
|
||||
}: CellWaveBackgroundProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cellRefs = useRef<(HTMLDivElement | null)[][]>([]);
|
||||
const timelinesRef = useRef<gsap.core.Timeline[]>([]);
|
||||
|
||||
const setCellRef = (colIndex: number, cellIndex: number) => (el: HTMLDivElement | null) => {
|
||||
if (!cellRefs.current[colIndex]) {
|
||||
cellRefs.current[colIndex] = [];
|
||||
}
|
||||
cellRefs.current[colIndex][cellIndex] = el;
|
||||
};
|
||||
|
||||
const cellStyles = {
|
||||
backgroundColor: cellColor,
|
||||
boxShadow: `0px 0px 50px 16px color-mix(in srgb, ${cellColor} 12%, transparent), 0px 0px 7px 1px color-mix(in srgb, ${cellColor} 31%, transparent)`
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
timelinesRef.current.forEach(tl => tl.kill());
|
||||
timelinesRef.current = [];
|
||||
|
||||
cellRefs.current.forEach((column, colIndex) => {
|
||||
const cells = [...column].filter(Boolean).reverse();
|
||||
const timeline = gsap.timeline({
|
||||
delay: delay * colIndex,
|
||||
repeat: -1,
|
||||
repeatDelay: 2
|
||||
});
|
||||
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
if (cell) {
|
||||
timeline.to(cell, {
|
||||
keyframes: [
|
||||
{ opacity: 0, duration: 0 },
|
||||
{ opacity: 0.05, duration: duration },
|
||||
{ opacity: 0.15, duration: duration },
|
||||
{ opacity: 0.25, duration: duration },
|
||||
{ opacity: 0.5, duration: duration },
|
||||
{ opacity: 0.25, duration: duration },
|
||||
{ opacity: 0.15, duration: duration },
|
||||
{ opacity: 0.05, duration: duration },
|
||||
{ opacity: 0, duration: duration }
|
||||
],
|
||||
ease: 'none'
|
||||
}, cellIndex * duration);
|
||||
}
|
||||
});
|
||||
|
||||
timelinesRef.current.push(timeline);
|
||||
});
|
||||
|
||||
return () => {
|
||||
timelinesRef.current.forEach(tl => tl.kill());
|
||||
};
|
||||
}, [duration, delay, columns, rows]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls("absolute inset-0 z-0 flex items-end justify-between pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<div className="relative flex flex-col gap-1 h-full" key={colIndex}>
|
||||
{Array.from({ length: rows }).map((_, cellIndex) => (
|
||||
<div
|
||||
ref={setCellRef(colIndex, cellIndex)}
|
||||
className="opacity-0 h-8 w-2"
|
||||
key={cellIndex}
|
||||
style={cellStyles}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CellWaveBackground.displayName = 'CellWaveBackground';
|
||||
|
||||
export default memo(CellWaveBackground);
|
||||
48
src/components/background/CircleGradientBackground.tsx
Normal file
48
src/components/background/CircleGradientBackground.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type DiagonalVariant = "primary" | "secondary";
|
||||
|
||||
interface CircleGradientBackgroundProps {
|
||||
className?: string;
|
||||
diagonal?: DiagonalVariant;
|
||||
}
|
||||
|
||||
const CircleGradientBackground = ({
|
||||
className = "",
|
||||
diagonal = "primary",
|
||||
}: CircleGradientBackgroundProps) => {
|
||||
const isPrimary = diagonal === "primary";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed top-0 left-0 right-0 bottom-0 h-screen w-full -z-10 overflow-hidden", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"fixed w-100 md:w-70 h-auto aspect-square rounded-full opacity-10",
|
||||
isPrimary ? "top-0 right-0 translate-x-1/2 -translate-y-1/2" : "top-0 left-0 -translate-x-1/2 -translate-y-1/2"
|
||||
)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, var(--color-background-accent) 35%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cls(
|
||||
"fixed w-100 md:w-70 h-auto aspect-square rounded-full opacity-10",
|
||||
isPrimary ? "bottom-0 left-0 -translate-x-1/2 translate-y-1/2" : "bottom-0 right-0 translate-x-1/2 translate-y-1/2"
|
||||
)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, var(--color-background-accent) 35%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CircleGradientBackground.displayName = "CircleGradientBackground";
|
||||
|
||||
export default memo(CircleGradientBackground);
|
||||
45
src/components/background/DotGridBackground.tsx
Normal file
45
src/components/background/DotGridBackground.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GridSize = "small" | "medium" | "large";
|
||||
|
||||
interface DotGridBackgroundProps {
|
||||
size?: GridSize;
|
||||
className?: string;
|
||||
perspectiveThreeD?: boolean;
|
||||
}
|
||||
|
||||
const GRID_SIZES: Record<GridSize, string> = {
|
||||
small: "1vw 1vw",
|
||||
medium: "2vw 2vw",
|
||||
large: "4vw 4vw",
|
||||
};
|
||||
|
||||
const DotGridBackground = ({
|
||||
size = "medium",
|
||||
className = "",
|
||||
perspectiveThreeD = false
|
||||
}: DotGridBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 -z-10 bg-background [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
|
||||
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--background-accent) 30%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: GRID_SIZES[size],
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DotGridBackground.displayName = "DotGridBackground";
|
||||
|
||||
export default memo(DotGridBackground);
|
||||
130
src/components/background/DownwardRaysBackground.tsx
Normal file
130
src/components/background/DownwardRaysBackground.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface RayConfig {
|
||||
width: number;
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
scale?: number;
|
||||
animationDuration: number;
|
||||
animationDelay: number;
|
||||
}
|
||||
|
||||
interface LightSourceConfig {
|
||||
width: number;
|
||||
height?: number;
|
||||
opacity: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface DownwardRaysBackgroundProps {
|
||||
animated: boolean;
|
||||
showGrid: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const rays: RayConfig[] = [
|
||||
{ width: 35, opacity: 1, rotation: -20, animationDuration: 4, animationDelay: 0 },
|
||||
{ width: 35, opacity: 0.6, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
|
||||
{ width: 20, opacity: 0.45, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
|
||||
{ width: 15, opacity: 0.625, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
|
||||
{ width: 40, opacity: 0.1, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
|
||||
{ width: 20, opacity: 0.525, rotation: 3, animationDuration: 3.2, animationDelay: 1.5 },
|
||||
{ width: 15, opacity: 0.725, rotation: 5, scale: 0.90, animationDuration: 4.2, animationDelay: 0.2 },
|
||||
{ width: 35, opacity: 0.6, rotation: 12, animationDuration: 3.8, animationDelay: 1 },
|
||||
{ width: 35, opacity: 1, rotation: 20, animationDuration: 4, animationDelay: 0.7 },
|
||||
];
|
||||
|
||||
const lightSources: LightSourceConfig[] = [
|
||||
{ width: 1198, opacity: 0.025, top: -352 },
|
||||
{ width: 865, height: 929, opacity: 0.1, top: -252 },
|
||||
{ width: 865, height: 929, opacity: 0.1, top: -252 },
|
||||
];
|
||||
|
||||
const DownwardRaysBackground = ({
|
||||
animated,
|
||||
showGrid,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
}: DownwardRaysBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{animated && (
|
||||
<style>
|
||||
{`
|
||||
@keyframes rayPulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: var(--target-opacity); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
|
||||
{showGrid && (
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-background [mask-image:radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, color-mix(in srgb, var(--color-background-accent) 20%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--color-background-accent) 10%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: "10vw 10vw",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"absolute overflow-hidden w-[1142px] h-[129vh] -top-[400px] left-1/2 -translate-x-1/2",
|
||||
"blur-[16px]",
|
||||
"[mask:radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
{rays.map((ray, index) => (
|
||||
<div
|
||||
key={`ray-${index}`}
|
||||
className="absolute overflow-hidden origin-top -top-[352px] -bottom-[920px] [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${ray.width}px`,
|
||||
left: `calc(50% - ${ray.width / 2}px)`,
|
||||
transform: `${ray.scale ? `scale(${ray.scale})` : ""} rotate(${ray.rotation}deg)`,
|
||||
...(animated
|
||||
? {
|
||||
"--target-opacity": ray.opacity,
|
||||
animation: `rayPulse ${ray.animationDuration}s ease-in-out ${ray.animationDelay}s infinite both`,
|
||||
}
|
||||
: {
|
||||
opacity: ray.opacity,
|
||||
}),
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
|
||||
{lightSources.map((source, index) => (
|
||||
<div
|
||||
key={`light-source-${index}`}
|
||||
className="absolute overflow-hidden [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${source.width}px`,
|
||||
height: source.height ? `${source.height}px` : undefined,
|
||||
top: `${source.top}px`,
|
||||
bottom: source.height ? undefined : "-46px",
|
||||
left: `calc(50% - ${source.width / 2}px)`,
|
||||
opacity: source.opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DownwardRaysBackground.displayName = "DownwardRaysBackground";
|
||||
|
||||
export default memo(DownwardRaysBackground);
|
||||
277
src/components/background/FluidBackground.tsx
Normal file
277
src/components/background/FluidBackground.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useMemo, memo, useEffect, useState } from 'react';
|
||||
import { Canvas, useFrame, extend, useThree } from '@react-three/fiber';
|
||||
import { shaderMaterial } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
const getComputedColor = (varName: string): THREE.Color => {
|
||||
if (typeof window === 'undefined') return new THREE.Color(0x000000);
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const colorString = styles.getPropertyValue(varName).trim();
|
||||
return new THREE.Color(colorString || '#000000');
|
||||
};
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
#ifdef GL_ES
|
||||
precision lowp float;
|
||||
#endif
|
||||
uniform float iTime;
|
||||
uniform vec2 iResolution;
|
||||
uniform vec3 uBackgroundColor;
|
||||
uniform vec3 uPrimaryCta;
|
||||
uniform vec3 uAccent;
|
||||
uniform vec3 uSecondaryCta;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec4 buf[8];
|
||||
|
||||
vec4 sigmoid(vec4 x) { return 1. / (1. + exp(-x)); }
|
||||
|
||||
vec4 cppn_fn(vec2 coordinate, float in0, float in1, float in2) {
|
||||
buf[6] = vec4(coordinate.x, coordinate.y, 0.3948333106474662 + in0, 0.36 + in1);
|
||||
buf[7] = vec4(0.14 + in2, sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y), 0., 0.);
|
||||
|
||||
buf[0] = mat4(vec4(6.5404263, -3.6126034, 0.7590882, -1.13613), vec4(2.4582713, 3.1660357, 1.2219609, 0.06276096), vec4(-5.478085, -6.159632, 1.8701609, -4.7742867), vec4(6.039214, -5.542865, -0.90925294, 3.251348))
|
||||
* buf[6]
|
||||
+ mat4(vec4(0.8473259, -5.722911, 3.975766, 1.6522468), vec4(-0.24321538, 0.5839259, -1.7661959, -5.350116), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(0.21808943, 1.1243913, -1.7969975, 5.0294676);
|
||||
|
||||
buf[1] = mat4(vec4(-3.3522482, -6.0612736, 0.55641043, -4.4719114), vec4(0.8631464, 1.7432913, 5.643898, 1.6106541), vec4(2.4941394, -3.5012043, 1.7184316, 6.357333), vec4(3.310376, 8.209261, 1.1355612, -1.165539))
|
||||
* buf[6]
|
||||
+ mat4(vec4(5.24046, -13.034365, 0.009859298, 15.870829), vec4(2.987511, 3.129433, -0.89023495, -1.6822904), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-5.9457836, -6.573602, -0.8812491, 1.5436668);
|
||||
|
||||
buf[0] = sigmoid(buf[0]);
|
||||
buf[1] = sigmoid(buf[1]);
|
||||
|
||||
buf[2] = mat4(vec4(-15.219568, 8.095543, -2.429353, -1.9381982), vec4(-5.951362, 4.3115187, 2.6393783, 1.274315), vec4(-7.3145227, 6.7297835, 5.2473326, 5.9411426), vec4(5.0796127, 8.979051, -1.7278991, -1.158976))
|
||||
* buf[6]
|
||||
+ mat4(vec4(-11.967154, -11.608155, 6.1486754, 11.237008), vec4(2.124141, -6.263192, -1.7050359, -0.7021966), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-4.17164, -3.2281182, -4.576417, -3.6401186);
|
||||
|
||||
buf[3] = mat4(vec4(3.1832156, -13.738922, 1.879223, 3.233465), vec4(0.64300746, 12.768129, 1.9141049, 0.50990224), vec4(-0.049295485, 4.4807224, 1.4733979, 1.801449), vec4(5.0039253, 13.000481, 3.3991797, -4.5561905))
|
||||
* buf[6]
|
||||
+ mat4(vec4(-0.1285731, 7.720628, -3.1425676, 4.742367), vec4(0.6393625, 3.714393, -0.8108378, -0.39174938), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-1.1811101, -21.621881, 0.7851888, 1.2329718);
|
||||
|
||||
buf[2] = sigmoid(buf[2]);
|
||||
buf[3] = sigmoid(buf[3]);
|
||||
|
||||
buf[4] = mat4(vec4(5.214916, -7.183024, 2.7228765, 2.6592617), vec4(-5.601878, -25.3591, 4.067988, 0.4602802), vec4(-10.57759, 24.286327, 21.102104, 37.546658), vec4(4.3024497, -1.9625226, 2.3458803, -1.372816))
|
||||
* buf[0]
|
||||
+ mat4(vec4(-17.6526, -10.507558, 2.2587414, 12.462782), vec4(6.265566, -502.75443, -12.642513, 0.9112289), vec4(-10.983244, 20.741234, -9.701768, -0.7635988), vec4(5.383626, 1.4819539, -4.1911616, -4.8444734))
|
||||
* buf[1]
|
||||
+ mat4(vec4(12.785233, -16.345072, -0.39901125, 1.7955981), vec4(-30.48365, -1.8345358, 1.4542528, -1.1118771), vec4(19.872723, -7.337935, -42.941723, -98.52709), vec4(8.337645, -2.7312303, -2.2927687, -36.142323))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-16.298317, 3.5471997, -0.44300047, -9.444417), vec4(57.5077, -35.609753, 16.163465, -4.1534753), vec4(-0.07470326, -3.8656476, -7.0901804, 3.1523974), vec4(-12.559385, -7.077619, 1.490437, -0.8211543))
|
||||
* buf[3]
|
||||
+ vec4(-7.67914, 15.927437, 1.3207729, -1.6686112);
|
||||
|
||||
buf[5] = mat4(vec4(-1.4109162, -0.372762, -3.770383, -21.367174), vec4(-6.2103205, -9.35908, 0.92529047, 8.82561), vec4(11.460242, -22.348068, 13.625772, -18.693201), vec4(-0.3429052, -3.9905605, -2.4626114, -0.45033523))
|
||||
* buf[0]
|
||||
+ mat4(vec4(7.3481627, -4.3661838, -6.3037653, -3.868115), vec4(1.5462853, 6.5488915, 1.9701879, -0.58291394), vec4(6.5858274, -2.2180402, 3.7127688, -1.3730392), vec4(-5.7973905, 10.134961, -2.3395722, -5.965605))
|
||||
* buf[1]
|
||||
+ mat4(vec4(-2.5132585, -6.6685553, -1.4029363, -0.16285264), vec4(-0.37908727, 0.53738135, 4.389061, -1.3024765), vec4(-0.70647055, 2.0111287, -5.1659346, -3.728635), vec4(-13.562562, 10.487719, -0.9173751, -2.6487076))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-8.645013, 6.5546675, -6.3944063, -5.5933375), vec4(-0.57783127, -1.077275, 36.91025, 5.736769), vec4(14.283112, 3.7146652, 7.1452246, -4.5958776), vec4(2.7192075, 3.6021907, -4.366337, -2.3653464))
|
||||
* buf[3]
|
||||
+ vec4(-5.9000807, -4.329569, 1.2427121, 8.59503);
|
||||
|
||||
buf[4] = sigmoid(buf[4]);
|
||||
buf[5] = sigmoid(buf[5]);
|
||||
|
||||
buf[6] = mat4(vec4(-1.61102, 0.7970257, 1.4675229, 0.20917463), vec4(-28.793737, -7.1390953, 1.5025433, 4.656581), vec4(-10.94861, 39.66238, 0.74318546, -10.095605), vec4(-0.7229728, -1.5483948, 0.7301322, 2.1687684))
|
||||
* buf[0]
|
||||
+ mat4(vec4(3.2547753, 21.489103, -1.0194173, -3.3100595), vec4(-3.7316632, -3.3792162, -7.223193, -0.23685838), vec4(13.1804495, 0.7916005, 5.338587, 5.687114), vec4(-4.167605, -17.798311, -6.815736, -1.6451967))
|
||||
* buf[1]
|
||||
+ mat4(vec4(0.604885, -7.800309, -7.213122, -2.741014), vec4(-3.522382, -0.12359311, -0.5258442, 0.43852118), vec4(9.6752825, -22.853785, 2.062431, 0.099892326), vec4(-4.3196306, -17.730087, 2.5184598, 5.30267))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-6.545563, -15.790176, -6.0438633, -5.415399), vec4(-43.591583, 28.551912, -16.00161, 18.84728), vec4(4.212382, 8.394307, 3.0958717, 8.657522), vec4(-5.0237565, -4.450633, -4.4768, -5.5010443))
|
||||
* buf[3]
|
||||
+ mat4(vec4(1.6985557, -67.05806, 6.897715, 1.9004834), vec4(1.8680354, 2.3915145, 2.5231109, 4.081538), vec4(11.158006, 1.7294737, 2.0738268, 7.386411), vec4(-4.256034, -306.24686, 8.258898, -17.132736))
|
||||
* buf[4]
|
||||
+ mat4(vec4(1.6889864, -4.5852966, 3.8534803, -6.3482175), vec4(1.3543309, -1.2640043, 9.932754, 2.9079645), vec4(-5.2770967, 0.07150358, -0.13962056, 3.3269649), vec4(28.34703, -4.918278, 6.1044083, 4.085355))
|
||||
* buf[5]
|
||||
+ vec4(6.6818056, 12.522166, -3.7075126, -4.104386);
|
||||
|
||||
buf[7] = mat4(vec4(-8.265602, -4.7027016, 5.098234, 0.7509808), vec4(8.6507845, -17.15949, 16.51939, -8.884479), vec4(-4.036479, -2.3946867, -2.6055532, -1.9866527), vec4(-2.2167742, -1.8135649, -5.9759874, 4.8846445))
|
||||
* buf[0]
|
||||
+ mat4(vec4(6.7790847, 3.5076547, -2.8191125, -2.7028968), vec4(-5.743024, -0.27844876, 1.4958696, -5.0517144), vec4(13.122226, 15.735168, -2.9397483, -4.101023), vec4(-14.375265, -5.030483, -6.2599335, 2.9848232))
|
||||
* buf[1]
|
||||
+ mat4(vec4(4.0950394, -0.94011575, -5.674733, 4.755022), vec4(4.3809423, 4.8310084, 1.7425908, -3.437416), vec4(2.117492, 0.16342592, -104.56341, 16.949184), vec4(-5.22543, -2.994248, 3.8350096, -1.9364246))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-5.900337, 1.7946124, -13.604192, -3.8060522), vec4(6.6583457, 31.911177, 25.164474, 91.81147), vec4(11.840538, 4.1503043, -0.7314397, 6.768467), vec4(-6.3967767, 4.034772, 6.1714606, -0.32874924))
|
||||
* buf[3]
|
||||
+ mat4(vec4(3.4992442, -196.91893, -8.923708, 2.8142626), vec4(3.4806502, -3.1846354, 5.1725626, 5.1804223), vec4(-2.4009497, 15.585794, 1.2863957, 2.0252278), vec4(-71.25271, -62.441242, -8.138444, 0.50670296))
|
||||
* buf[4]
|
||||
+ mat4(vec4(-12.291733, -11.176166, -7.3474145, 4.390294), vec4(10.805477, 5.6337385, -0.9385842, -4.7348723), vec4(-12.869276, -7.039391, 5.3029537, 7.5436664), vec4(1.4593618, 8.91898, 3.5101583, 5.840625))
|
||||
* buf[5]
|
||||
+ vec4(2.2415268, -6.705987, -0.98861027, -2.117676);
|
||||
|
||||
buf[6] = sigmoid(buf[6]);
|
||||
buf[7] = sigmoid(buf[7]);
|
||||
|
||||
buf[0] = mat4(vec4(1.6794263, 1.3817469, 2.9625452, 0.0), vec4(-1.8834411, -1.4806935, -3.5924516, 0.0), vec4(-1.3279216, -1.0918057, -2.3124623, 0.0), vec4(0.2662234, 0.23235129, 0.44178495, 0.0))
|
||||
* buf[0]
|
||||
+ mat4(vec4(-0.6299101, -0.5945583, -0.9125601, 0.0), vec4(0.17828953, 0.18300213, 0.18182953, 0.0), vec4(-2.96544, -2.5819945, -4.9001055, 0.0), vec4(1.4195864, 1.1868085, 2.5176322, 0.0))
|
||||
* buf[1]
|
||||
+ mat4(vec4(-1.2584374, -1.0552157, -2.1688404, 0.0), vec4(-0.7200217, -0.52666044, -1.438251, 0.0), vec4(0.15345335, 0.15196142, 0.272854, 0.0), vec4(0.945728, 0.8861938, 1.2766753, 0.0))
|
||||
* buf[2]
|
||||
+ mat4(vec4(-2.4218085, -1.968602, -4.35166, 0.0), vec4(-22.683098, -18.0544, -41.954372, 0.0), vec4(0.63792, 0.5470648, 1.1078634, 0.0), vec4(-1.5489894, -1.3075932, -2.6444845, 0.0))
|
||||
* buf[3]
|
||||
+ mat4(vec4(-0.49252132, -0.39877754, -0.91366625, 0.0), vec4(0.95609266, 0.7923952, 1.640221, 0.0), vec4(0.30616966, 0.15693925, 0.8639857, 0.0), vec4(1.1825981, 0.94504964, 2.176963, 0.0))
|
||||
* buf[4]
|
||||
+ mat4(vec4(0.35446745, 0.3293795, 0.59547555, 0.0), vec4(-0.58784515, -0.48177817, -1.0614829, 0.0), vec4(2.5271258, 1.9991658, 4.6846647, 0.0), vec4(0.13042648, 0.08864098, 0.30187556, 0.0))
|
||||
* buf[5]
|
||||
+ mat4(vec4(-1.7718065, -1.4033192, -3.3355875, 0.0), vec4(3.1664357, 2.638297, 5.378702, 0.0), vec4(-3.1724713, -2.6107926, -5.549295, 0.0), vec4(-2.851368, -2.249092, -5.3013067, 0.0))
|
||||
* buf[6]
|
||||
+ mat4(vec4(1.5203838, 1.2212278, 2.8404984, 0.0), vec4(1.5210563, 1.2651345, 2.683903, 0.0), vec4(2.9789467, 2.4364579, 5.2347264, 0.0), vec4(2.2270417, 1.8825914, 3.8028636, 0.0))
|
||||
* buf[7]
|
||||
+ vec4(-1.5468478, -3.6171484, 0.24762098, 0.0);
|
||||
|
||||
buf[0] = sigmoid(buf[0]);
|
||||
return vec4(buf[0].x , buf[0].y , buf[0].z, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv * 2.0 - 1.0; uv.y *= -1.0;
|
||||
vec4 pattern = cppn_fn(uv, 0.1 * sin(0.3 * iTime), 0.1 * sin(0.69 * iTime), 0.1 * sin(0.44 * iTime));
|
||||
|
||||
vec3 color1 = mix(uBackgroundColor, uPrimaryCta, pattern.x);
|
||||
vec3 color2 = mix(uBackgroundColor, uAccent, pattern.y);
|
||||
vec3 color3 = mix(uBackgroundColor, uSecondaryCta, pattern.z);
|
||||
|
||||
vec3 finalColor = (color1 + color2 + color3) / 3.0;
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const CPPNShaderMaterial = shaderMaterial(
|
||||
{
|
||||
iTime: 0,
|
||||
iResolution: new THREE.Vector2(1, 1),
|
||||
uBackgroundColor: new THREE.Color(0x000000),
|
||||
uPrimaryCta: new THREE.Color(0xff0000),
|
||||
uAccent: new THREE.Color(0x00ff00),
|
||||
uSecondaryCta: new THREE.Color(0x0000ff),
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader
|
||||
);
|
||||
|
||||
extend({ CPPNShaderMaterial });
|
||||
|
||||
interface ShaderPlaneProps {
|
||||
backgroundColor: THREE.Color;
|
||||
primaryCta: THREE.Color;
|
||||
accent: THREE.Color;
|
||||
secondaryCta: THREE.Color;
|
||||
}
|
||||
|
||||
const ShaderPlane = memo(({ backgroundColor, primaryCta, accent, secondaryCta }: ShaderPlaneProps) => {
|
||||
const meshRef = useRef<THREE.Mesh>(null!);
|
||||
const materialRef = useRef<THREE.ShaderMaterial & {
|
||||
iTime: number;
|
||||
iResolution: THREE.Vector2;
|
||||
uBackgroundColor: THREE.Color;
|
||||
uPrimaryCta: THREE.Color;
|
||||
uAccent: THREE.Color;
|
||||
uSecondaryCta: THREE.Color;
|
||||
}>(null!);
|
||||
const { viewport } = useThree();
|
||||
|
||||
useFrame((state) => {
|
||||
if (!materialRef.current) return;
|
||||
materialRef.current.iTime = state.clock.elapsedTime;
|
||||
const { width, height } = state.size;
|
||||
materialRef.current.iResolution.set(width, height);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!materialRef.current) return;
|
||||
materialRef.current.uBackgroundColor = backgroundColor;
|
||||
materialRef.current.uPrimaryCta = primaryCta;
|
||||
materialRef.current.uAccent = accent;
|
||||
materialRef.current.uSecondaryCta = secondaryCta;
|
||||
}, [backgroundColor, primaryCta, accent, secondaryCta]);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[viewport.width, viewport.height]} />
|
||||
<cPPNShaderMaterial ref={materialRef} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
});
|
||||
|
||||
ShaderPlane.displayName = 'ShaderPlane';
|
||||
|
||||
interface FluidBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FluidBackground = ({ className = "" }: FluidBackgroundProps) => {
|
||||
const camera = useMemo(() => ({ position: [0, 0, 5] as [number, number, number], fov: 75, near: 0.1, far: 1000 }), []);
|
||||
|
||||
const [colors, setColors] = useState({
|
||||
background: new THREE.Color(0x000000),
|
||||
primaryCta: new THREE.Color(0xff0000),
|
||||
accent: new THREE.Color(0x00ff00),
|
||||
secondaryCta: new THREE.Color(0x0000ff),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateColors = () => {
|
||||
setColors({
|
||||
background: getComputedColor('--background'),
|
||||
primaryCta: getComputedColor('--color-background-accent'),
|
||||
accent: getComputedColor('--color-background-accent'),
|
||||
secondaryCta: getComputedColor('--color-background-accent'),
|
||||
});
|
||||
};
|
||||
|
||||
updateColors();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cls("bg-background fixed inset-0 -z-10 w-full h-full", className)} aria-hidden="true">
|
||||
<Canvas
|
||||
camera={camera}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
dpr={[1, 2]}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<ShaderPlane
|
||||
backgroundColor={colors.background}
|
||||
primaryCta={colors.primaryCta}
|
||||
accent={colors.accent}
|
||||
secondaryCta={colors.secondaryCta}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FluidBackground.displayName = 'FluidBackground';
|
||||
|
||||
export default memo(FluidBackground);
|
||||
|
||||
declare module '@react-three/fiber' {
|
||||
interface ThreeElements {
|
||||
cPPNShaderMaterial: unknown;
|
||||
}
|
||||
}
|
||||
272
src/components/background/GlowingEffect.tsx
Normal file
272
src/components/background/GlowingEffect.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { animate } from "motion/react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const INACTIVE_ZONE_MULTIPLIER = 0.5;
|
||||
const CENTER_MULTIPLIER = 0.5;
|
||||
const ANGLE_CONVERSION_FACTOR = 180 / Math.PI;
|
||||
const ANGLE_OFFSET = 90;
|
||||
const ANGLE_NORMALIZATION = 180;
|
||||
const FULL_CIRCLE = 360;
|
||||
const REPEATING_GRADIENT_TIMES = 5;
|
||||
const GRADIENT_DIVISION = 25;
|
||||
|
||||
const ANIMATION_EASING = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
interface GlowingEffectProps {
|
||||
blur?: number;
|
||||
inactiveZone?: number;
|
||||
proximity?: number;
|
||||
spread?: number;
|
||||
glow?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
movementDuration?: number;
|
||||
borderWidth?: number;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type MouseEventLike = MouseEvent | Position;
|
||||
const getIsSSR = () => typeof window === "undefined";
|
||||
|
||||
const getViewportCenter = (): Position => {
|
||||
if (getIsSSR()) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const getIsMobileDevice = (): boolean => {
|
||||
if (getIsSSR()) return false;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||
};
|
||||
|
||||
const calculateAngleDiff = (current: number, target: number): number => {
|
||||
return ((target - current + ANGLE_NORMALIZATION) % FULL_CIRCLE) - ANGLE_NORMALIZATION;
|
||||
};
|
||||
|
||||
const GlowingEffect = memo(
|
||||
({
|
||||
blur = 0,
|
||||
inactiveZone = 0.7,
|
||||
proximity = 0,
|
||||
spread = 20,
|
||||
glow = false,
|
||||
className,
|
||||
movementDuration = 2,
|
||||
borderWidth = 1,
|
||||
disabled = true,
|
||||
}: GlowingEffectProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastPosition = useRef<Position>({ x: 0, y: 0 });
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const [isMobile, setIsMobile] = useState(() => getIsMobileDevice());
|
||||
|
||||
const updateElementStyles = useCallback(
|
||||
(element: HTMLElement, property: string, value: string) => {
|
||||
element.style.setProperty(property, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const calculateMousePosition = useCallback(
|
||||
(e?: MouseEventLike): Position => {
|
||||
if (isMobile) {
|
||||
return getViewportCenter();
|
||||
}
|
||||
return {
|
||||
x: e?.x ?? lastPosition.current.x,
|
||||
y: e?.y ?? lastPosition.current.y,
|
||||
};
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const animateAngleTransition = useCallback(
|
||||
(element: HTMLElement, currentAngle: number, targetAngle: number) => {
|
||||
const angleDiff = calculateAngleDiff(currentAngle, targetAngle);
|
||||
const newAngle = currentAngle + angleDiff;
|
||||
|
||||
animate(currentAngle, newAngle, {
|
||||
duration: movementDuration,
|
||||
ease: ANIMATION_EASING,
|
||||
onUpdate: (value) => {
|
||||
updateElementStyles(element, "--start", String(value));
|
||||
},
|
||||
});
|
||||
},
|
||||
[movementDuration, updateElementStyles]
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(e?: MouseEventLike) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
const mousePosition = calculateMousePosition(e);
|
||||
|
||||
if (e) {
|
||||
lastPosition.current = mousePosition;
|
||||
}
|
||||
|
||||
const centerX = left + width * CENTER_MULTIPLIER;
|
||||
const centerY = top + height * CENTER_MULTIPLIER;
|
||||
const distanceFromCenter = Math.hypot(
|
||||
mousePosition.x - centerX,
|
||||
mousePosition.y - centerY
|
||||
);
|
||||
const inactiveRadius = INACTIVE_ZONE_MULTIPLIER * Math.min(width, height) * inactiveZone;
|
||||
|
||||
if (distanceFromCenter < inactiveRadius) {
|
||||
updateElementStyles(element, "--active", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive =
|
||||
mousePosition.x > left - proximity &&
|
||||
mousePosition.x < left + width + proximity &&
|
||||
mousePosition.y > top - proximity &&
|
||||
mousePosition.y < top + height + proximity;
|
||||
|
||||
updateElementStyles(element, "--active", isActive ? "1" : "0");
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
const currentAngle =
|
||||
parseFloat(element.style.getPropertyValue("--start")) || 0;
|
||||
const targetAngle =
|
||||
ANGLE_CONVERSION_FACTOR * Math.atan2(mousePosition.y - centerY, mousePosition.x - centerX) +
|
||||
ANGLE_OFFSET;
|
||||
|
||||
animateAngleTransition(element, currentAngle, targetAngle);
|
||||
});
|
||||
},
|
||||
[inactiveZone, proximity, calculateMousePosition, updateElementStyles, animateAngleTransition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsSSR()) return;
|
||||
|
||||
const checkMobile = () => {
|
||||
setIsMobile(getIsMobileDevice());
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || getIsSSR()) return;
|
||||
|
||||
const handleScroll = () => handleMove();
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isMobile) {
|
||||
handleMove(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
handleMove();
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
document.body.addEventListener("pointermove", handlePointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.body.removeEventListener("pointermove", handlePointerMove);
|
||||
};
|
||||
}, [handleMove, disabled, isMobile]);
|
||||
|
||||
const gradient = useMemo(
|
||||
() => `radial-gradient(circle, var(--accent) 10%, transparent 20%),
|
||||
radial-gradient(circle at 40% 40%, var(--background-accent) 5%, transparent 15%),
|
||||
repeating-conic-gradient(
|
||||
from 236.84deg at 50% 50%,
|
||||
var(--accent) 0%,
|
||||
var(--background-accent) calc(${GRADIENT_DIVISION}% / var(--repeating-conic-gradient-times)),
|
||||
var(--accent) calc(${GRADIENT_DIVISION * 2}% / var(--repeating-conic-gradient-times))
|
||||
)`,
|
||||
[]
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
"--blur": `${blur}px`,
|
||||
"--spread": spread,
|
||||
"--start": "0",
|
||||
"--active": "0",
|
||||
"--glowingeffect-border-width": `${borderWidth}px`,
|
||||
"--repeating-conic-gradient-times": String(REPEATING_GRADIENT_TIMES),
|
||||
"--gradient": gradient,
|
||||
} as React.CSSProperties),
|
||||
[blur, spread, borderWidth, gradient]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cls(
|
||||
"pointer-events-none absolute inset-0 hidden rounded-[inherit] border opacity-0 transition-opacity",
|
||||
glow && "opacity-100",
|
||||
disabled && "!block"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={containerStyle}
|
||||
className={cls(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
|
||||
glow && "opacity-100",
|
||||
blur > 0 && "blur-[var(--blur)] ",
|
||||
className,
|
||||
disabled && "!hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"glow",
|
||||
"rounded-[inherit]",
|
||||
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
|
||||
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
|
||||
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
|
||||
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
|
||||
"after:[mask-clip:padding-box,border-box]",
|
||||
"after:[mask-composite:intersect]",
|
||||
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GlowingEffect.displayName = "GlowingEffect";
|
||||
|
||||
export { GlowingEffect };
|
||||
52
src/components/background/GlowingOrbBackground.tsx
Normal file
52
src/components/background/GlowingOrbBackground.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface GlowingOrbBackgroundProps {
|
||||
className?: string;
|
||||
blurAmount?: string;
|
||||
glowColor?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const GlowingOrbBackground = ({
|
||||
className = "",
|
||||
blurAmount = "57px",
|
||||
glowColor = "var(--color-primary-cta)",
|
||||
backgroundColor = "var(--background)",
|
||||
}: GlowingOrbBackgroundProps) => {
|
||||
return (
|
||||
<div className="absolute z-0 top-0 left-0 w-full h-screen overflow-hidden pointer-events-none select-none [mask-image:linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]" aria-hidden="true">
|
||||
<div
|
||||
className={cls("absolute left-1/2 -translate-x-1/2 w-full h-[100vh] -bottom-[9vh] overflow-hidden z-0", className)}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, ${glowColor} 25%, transparent), transparent)`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-[100vh] rounded-[100%]"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, color-mix(in srgb, ${glowColor} 30%, transparent), transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%]"
|
||||
style={{
|
||||
backgroundColor,
|
||||
boxShadow: `inset 0 2px 20px color-mix(in srgb, ${glowColor} 30%, transparent), 0 -10px 50px 1px color-mix(in srgb, ${glowColor} 25%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GlowingOrbBackground.displayName = 'GlowingOrbBackground';
|
||||
|
||||
export default memo(GlowingOrbBackground);
|
||||
82
src/components/background/GlowingOrbSparklesBackground.tsx
Normal file
82
src/components/background/GlowingOrbSparklesBackground.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Sparkles } from './Sparkles';
|
||||
|
||||
interface GlowingOrbSparklesBackgroundProps {
|
||||
className?: string;
|
||||
blurAmount?: string;
|
||||
glowColor?: string;
|
||||
backgroundColor?: string;
|
||||
particleColor?: string;
|
||||
particleDensity?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
const GlowingOrbSparklesBackground = ({
|
||||
className = "",
|
||||
blurAmount = "57px",
|
||||
glowColor = "var(--color-primary-cta)",
|
||||
backgroundColor = "var(--background)",
|
||||
particleColor = "var(--color-primary-cta)",
|
||||
particleDensity = 80,
|
||||
minSize = 0.5,
|
||||
maxSize = 1.5,
|
||||
speed = 4,
|
||||
}: GlowingOrbSparklesBackgroundProps) => {
|
||||
return (
|
||||
<div className="absolute z-0 top-0 left-0 w-full h-screen overflow-hidden pointer-events-none select-none [mask-image:linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]" aria-hidden="true">
|
||||
{/* Sparkles layer with radial mask */}
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(circle at 50% 50%, rgb(0,0,0) 0%, rgb(0,0,0) 20%, rgba(0,0,0,0) 50%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 50% 50%, rgb(0,0,0) 0%, rgb(0,0,0) 20%, rgba(0,0,0,0) 50%)',
|
||||
}}
|
||||
>
|
||||
<Sparkles
|
||||
className="absolute inset-0"
|
||||
particleColor={particleColor}
|
||||
particleDensity={particleDensity}
|
||||
minSize={minSize}
|
||||
maxSize={maxSize}
|
||||
speed={speed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Glowing orb layer */}
|
||||
<div
|
||||
className={cls("absolute left-1/2 -translate-x-1/2 w-full h-[100vh] -bottom-[9vh] overflow-hidden z-0", className)}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, ${glowColor} 25%, transparent), transparent)`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-[100vh] rounded-[100%]"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, color-mix(in srgb, ${glowColor} 30%, transparent), transparent)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%]"
|
||||
style={{
|
||||
backgroundColor,
|
||||
boxShadow: `inset 0 2px 20px color-mix(in srgb, ${glowColor} 30%, transparent), 0 -10px 50px 1px color-mix(in srgb, ${glowColor} 25%, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GlowingOrbSparklesBackground.displayName = 'GlowingOrbSparklesBackground';
|
||||
|
||||
export default memo(GlowingOrbSparklesBackground);
|
||||
74
src/components/background/GradientBarsBackground.tsx
Normal file
74
src/components/background/GradientBarsBackground.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface GradientBarsBackgroundProps {
|
||||
className?: string;
|
||||
numBarsPerSide?: number;
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
opacity?: number;
|
||||
sideWidth?: string;
|
||||
}
|
||||
|
||||
const GradientBarsBackground = ({
|
||||
className = "",
|
||||
numBarsPerSide = 8,
|
||||
gradientFrom = "var(--color-primary-cta)",
|
||||
gradientTo = "transparent",
|
||||
opacity = 0.075,
|
||||
sideWidth = "35%",
|
||||
}: GradientBarsBackgroundProps) => {
|
||||
const getBarStyle = (side: 'left' | 'right') => ({
|
||||
flex: '1 0 0',
|
||||
minWidth: '30px',
|
||||
maxWidth: '82px',
|
||||
background: `linear-gradient(${side === 'left' ? '90deg' : '270deg'}, ${gradientFrom}, ${gradientTo})`,
|
||||
opacity: opacity,
|
||||
});
|
||||
|
||||
const renderBars = (side: 'left' | 'right') =>
|
||||
Array.from({ length: numBarsPerSide }).map((_, index) => (
|
||||
<div key={`${side}-${index}`} className="h-full" style={getBarStyle(side)} />
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="flex h-8/10 w-full justify-between backface-hidden antialiased"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
mask: 'linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-full overflow-hidden"
|
||||
style={{
|
||||
width: sideWidth,
|
||||
mask: 'linear-gradient(270deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
{renderBars('left')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-full justify-end overflow-hidden"
|
||||
style={{
|
||||
width: sideWidth,
|
||||
mask: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
{renderBars('right')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GradientBarsBackground.displayName = 'GradientBarsBackground';
|
||||
|
||||
export default memo(GradientBarsBackground);
|
||||
45
src/components/background/GridBackround.tsx
Normal file
45
src/components/background/GridBackround.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type GridSize = "small" | "medium" | "large";
|
||||
|
||||
interface GridBackroundProps {
|
||||
size?: GridSize;
|
||||
className?: string;
|
||||
perspectiveThreeD?: boolean;
|
||||
}
|
||||
|
||||
const GRID_SIZES: Record<GridSize, string> = {
|
||||
small: "6.25vw 6.25vw",
|
||||
medium: "10vw 10vw",
|
||||
large: "20vw 20vw",
|
||||
};
|
||||
|
||||
const GridBackround = ({
|
||||
size = "medium",
|
||||
className = "",
|
||||
perspectiveThreeD = false
|
||||
}: GridBackroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed inset-0 -z-10 bg-background [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
|
||||
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, color-mix(in srgb, var(--background-accent) 10%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--background-accent) 10%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: GRID_SIZES[size],
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
GridBackround.displayName = "GridBackround";
|
||||
|
||||
export default memo(GridBackround);
|
||||
121
src/components/background/HeroBackgrounds.tsx
Normal file
121
src/components/background/HeroBackgrounds.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import AnimatedGridBackground from "./AnimatedGridBackground";
|
||||
import CanvasRevealBackground from "./CanvasRevealBackground";
|
||||
import CellWaveBackground from "./CellWaveBackground";
|
||||
import DownwardRaysBackground from "./DownwardRaysBackground";
|
||||
import GlowingOrbBackground from "./GlowingOrbBackground";
|
||||
import GlowingOrbSparklesBackground from "./GlowingOrbSparklesBackground";
|
||||
import GradientBarsBackground from "./GradientBarsBackground";
|
||||
import RadialGradientBackground from "./RadialGradientBackground";
|
||||
import RotatedRaysBackground from "./RotatedRaysBackground";
|
||||
import RotatingGradientBackground from "./RotatingGradientBackground";
|
||||
import SparklesGradientBackground from "./SparklesGradientBackground";
|
||||
|
||||
export type HeroBackgroundVariant =
|
||||
| "plain"
|
||||
| "animated-grid"
|
||||
| "canvas-reveal"
|
||||
| "cell-wave"
|
||||
| "downward-rays-animated"
|
||||
| "downward-rays-animated-grid"
|
||||
| "downward-rays-static"
|
||||
| "downward-rays-static-grid"
|
||||
| "glowing-orb"
|
||||
| "glowing-orb-sparkles"
|
||||
| "gradient-bars"
|
||||
| "radial-gradient"
|
||||
| "rotated-rays-animated"
|
||||
| "rotated-rays-animated-grid"
|
||||
| "rotated-rays-static"
|
||||
| "rotated-rays-static-grid"
|
||||
| "rotating-gradient"
|
||||
| "sparkles-gradient";
|
||||
|
||||
type AnimatedGridProps = React.ComponentProps<typeof AnimatedGridBackground>;
|
||||
type CanvasRevealProps = React.ComponentProps<typeof CanvasRevealBackground>;
|
||||
type CellWaveProps = React.ComponentProps<typeof CellWaveBackground>;
|
||||
type GlowingOrbProps = React.ComponentProps<typeof GlowingOrbBackground>;
|
||||
type GlowingOrbSparklesProps = React.ComponentProps<typeof GlowingOrbSparklesBackground>;
|
||||
type GradientBarsProps = React.ComponentProps<typeof GradientBarsBackground>;
|
||||
type RadialGradientProps = React.ComponentProps<typeof RadialGradientBackground>;
|
||||
type RotatingGradientProps = React.ComponentProps<typeof RotatingGradientBackground>;
|
||||
type SparklesGradientProps = React.ComponentProps<typeof SparklesGradientBackground>;
|
||||
|
||||
export type HeroBackgroundVariantProps =
|
||||
| { variant: "plain" }
|
||||
| ({ variant: "animated-grid" } & AnimatedGridProps)
|
||||
| ({ variant: "canvas-reveal" } & CanvasRevealProps)
|
||||
| ({ variant: "cell-wave" } & CellWaveProps)
|
||||
| { variant: "downward-rays-animated" }
|
||||
| { variant: "downward-rays-animated-grid" }
|
||||
| { variant: "downward-rays-static" }
|
||||
| { variant: "downward-rays-static-grid" }
|
||||
| ({ variant: "glowing-orb" } & GlowingOrbProps)
|
||||
| ({ variant: "glowing-orb-sparkles" } & GlowingOrbSparklesProps)
|
||||
| ({ variant: "gradient-bars" } & GradientBarsProps)
|
||||
| ({ variant: "radial-gradient" } & RadialGradientProps)
|
||||
| { variant: "rotated-rays-animated" }
|
||||
| { variant: "rotated-rays-animated-grid" }
|
||||
| { variant: "rotated-rays-static" }
|
||||
| { variant: "rotated-rays-static-grid" }
|
||||
| ({ variant: "rotating-gradient" } & RotatingGradientProps)
|
||||
| ({ variant: "sparkles-gradient" } & SparklesGradientProps);
|
||||
|
||||
const heroBackgroundComponents = {
|
||||
"animated-grid": AnimatedGridBackground,
|
||||
"canvas-reveal": CanvasRevealBackground,
|
||||
"cell-wave": CellWaveBackground,
|
||||
"downward-rays": DownwardRaysBackground,
|
||||
"glowing-orb": GlowingOrbBackground,
|
||||
"glowing-orb-sparkles": GlowingOrbSparklesBackground,
|
||||
"gradient-bars": GradientBarsBackground,
|
||||
"radial-gradient": RadialGradientBackground,
|
||||
"rotated-rays": RotatedRaysBackground,
|
||||
"rotating-gradient": RotatingGradientBackground,
|
||||
"sparkles-gradient": SparklesGradientBackground,
|
||||
} as const;
|
||||
|
||||
const HeroBackgrounds = (props: HeroBackgroundVariantProps) => {
|
||||
if (props.variant === "plain") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { variant, ...restProps } = props;
|
||||
|
||||
// Handle rotated-rays preset variants
|
||||
if (variant === "rotated-rays-animated") {
|
||||
return <RotatedRaysBackground animated={true} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "rotated-rays-animated-grid") {
|
||||
return <RotatedRaysBackground animated={true} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "rotated-rays-static") {
|
||||
return <RotatedRaysBackground animated={false} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "rotated-rays-static-grid") {
|
||||
return <RotatedRaysBackground animated={false} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
|
||||
// Handle downward-rays preset variants
|
||||
if (variant === "downward-rays-animated") {
|
||||
return <DownwardRaysBackground animated={true} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "downward-rays-animated-grid") {
|
||||
return <DownwardRaysBackground animated={true} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "downward-rays-static") {
|
||||
return <DownwardRaysBackground animated={false} showGrid={false} {...(restProps as any)} />;
|
||||
}
|
||||
if (variant === "downward-rays-static-grid") {
|
||||
return <DownwardRaysBackground animated={false} showGrid={true} {...(restProps as any)} />;
|
||||
}
|
||||
|
||||
const BackgroundComponent = heroBackgroundComponents[variant];
|
||||
return <BackgroundComponent {...(restProps as any)} />;
|
||||
};
|
||||
|
||||
HeroBackgrounds.displayName = "HeroBackgrounds";
|
||||
|
||||
export default memo(HeroBackgrounds);
|
||||
31
src/components/background/NoiseBackground.tsx
Normal file
31
src/components/background/NoiseBackground.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface NoiseBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoiseBackground = ({ className = "" }: NoiseBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-background-accent/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-10"
|
||||
style={{
|
||||
backgroundImage: "url(https://webuild-dev.s3.eu-north-1.amazonaws.com/default/noise.webp)",
|
||||
backgroundSize: "512px"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoiseBackground.displayName = "NoiseBackground";
|
||||
|
||||
export default memo(NoiseBackground);
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface NoiseDiagonalGradientBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoiseDiagonalGradientBackground = ({ className = "" }: NoiseDiagonalGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-background-accent/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-br from-background via-background-accent/10 to-background-accent/20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-10"
|
||||
style={{
|
||||
backgroundImage: "url(https://webuild-dev.s3.eu-north-1.amazonaws.com/default/noise.webp)",
|
||||
backgroundSize: "512px"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoiseDiagonalGradientBackground.displayName = "NoiseDiagonalGradientBackground";
|
||||
|
||||
export default memo(NoiseDiagonalGradientBackground);
|
||||
35
src/components/background/NoiseGradientBackground.tsx
Normal file
35
src/components/background/NoiseGradientBackground.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface NoiseGradientBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NoiseGradientBackground = ({ className = "" }: NoiseGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-accent/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-r from-background via-accent/20 to-primary-cta/20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-12"
|
||||
style={{
|
||||
backgroundImage: "url(https://webuild-dev.s3.eu-north-1.amazonaws.com/default/noise.webp)",
|
||||
backgroundSize: "512px"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoiseGradientBackground.displayName = "NoiseGradientBackground";
|
||||
|
||||
export default memo(NoiseGradientBackground);
|
||||
21
src/components/background/PlainBackground.tsx
Normal file
21
src/components/background/PlainBackground.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface PlainBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PlainBackground = ({ className = "" }: PlainBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("fixed inset-0 -z-10 bg-background", className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
PlainBackground.displayName = "PlainBackground";
|
||||
|
||||
export default memo(PlainBackground);
|
||||
40
src/components/background/RadialGradientBackground.tsx
Normal file
40
src/components/background/RadialGradientBackground.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
|
||||
interface RadialGradientBackgroundProps {
|
||||
className?: string;
|
||||
centerColor?: string;
|
||||
edgeColor?: string;
|
||||
size?: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
const RadialGradientBackground = ({
|
||||
className = "",
|
||||
centerColor = "var(--background)",
|
||||
edgeColor = "var(--color-background-accent)",
|
||||
size = "130% 130%",
|
||||
position = "50% 15%",
|
||||
}: RadialGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 pointer-events-none select-none md:px-5 md:pb-5", className)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-full rounded-b-theme-capped"
|
||||
style={{
|
||||
background: `radial-gradient(${size} at ${position}, ${centerColor} 40%, ${edgeColor} 100%)`,
|
||||
mask: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 15%, rgb(0, 0, 0) 55%, rgb(0, 0, 0) 100%)',
|
||||
WebkitMask: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 15%, rgb(0, 0, 0) 55%, rgb(0, 0, 0) 100%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RadialGradientBackground.displayName = 'RadialGradientBackground';
|
||||
|
||||
export default memo(RadialGradientBackground);
|
||||
130
src/components/background/RotatedRaysBackground.tsx
Normal file
130
src/components/background/RotatedRaysBackground.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface RayConfig {
|
||||
width: number;
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
scale?: number;
|
||||
left?: string;
|
||||
animationDuration: number;
|
||||
animationDelay: number;
|
||||
}
|
||||
|
||||
interface LightSourceConfig {
|
||||
width: number;
|
||||
height?: number;
|
||||
opacity: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface RotatedRaysBackgroundProps {
|
||||
animated: boolean;
|
||||
showGrid: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const rays: RayConfig[] = [
|
||||
{ width: 35, opacity: 0.85, rotation: -18, animationDuration: 4, animationDelay: 0 },
|
||||
{ width: 35, opacity: 0.775, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
|
||||
{ width: 20, opacity: 0.65, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
|
||||
{ width: 15, opacity: 0.25, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
|
||||
{ width: 40, opacity: 0.45, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
|
||||
{ width: 20, opacity: 0.45, rotation: 6, animationDuration: 3.2, animationDelay: 1.5 },
|
||||
{ width: 35, opacity: 0.65, rotation: 9, scale: 0.83, animationDuration: 4.2, animationDelay: 0.2 },
|
||||
{ width: 35, opacity: 1, rotation: 14, scale: 0.9, animationDuration: 3.8, animationDelay: 1 },
|
||||
];
|
||||
|
||||
const lightSources: LightSourceConfig[] = [
|
||||
{ width: 1198, opacity: 0.05, top: -352 },
|
||||
{ width: 865, height: 929, opacity: 0.15, top: -252 },
|
||||
{ width: 865, height: 929, opacity: 0.15, top: -252 },
|
||||
];
|
||||
|
||||
const RotatedRaysBackground = ({
|
||||
animated,
|
||||
showGrid,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
}: RotatedRaysBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{animated && (
|
||||
<style>
|
||||
{`
|
||||
@keyframes rotatedRayPulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: var(--target-opacity); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
|
||||
{showGrid && (
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-background [mask-image:radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, color-mix(in srgb, var(--color-background-accent) 20%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--color-background-accent) 10%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: "10vw 10vw",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"absolute overflow-hidden w-[1142px] h-[179vh] -top-[571px] -left-[373px]",
|
||||
"-rotate-[38deg] blur-[16px]",
|
||||
"[mask:radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
{rays.map((ray, index) => (
|
||||
<div
|
||||
key={`ray-${index}`}
|
||||
className="absolute overflow-hidden origin-top-right -top-[352px] -bottom-[920px] [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${ray.width}px`,
|
||||
left: ray.left || `calc(50% - ${ray.width / 2}px)`,
|
||||
transform: `${ray.scale ? `scale(${ray.scale})` : ""} rotate(${ray.rotation}deg)`,
|
||||
...(animated
|
||||
? {
|
||||
"--target-opacity": ray.opacity,
|
||||
animation: `rotatedRayPulse ${ray.animationDuration}s ease-in-out ${ray.animationDelay}s infinite both`,
|
||||
}
|
||||
: {
|
||||
opacity: ray.opacity,
|
||||
}),
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
|
||||
{lightSources.map((source, index) => (
|
||||
<div
|
||||
key={`light-source-${index}`}
|
||||
className="absolute overflow-hidden [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
|
||||
style={{
|
||||
width: `${source.width}px`,
|
||||
height: source.height ? `${source.height}px` : undefined,
|
||||
top: `${source.top}px`,
|
||||
bottom: source.height ? undefined : "-46px",
|
||||
left: `calc(50% - ${source.width / 2}px)`,
|
||||
opacity: source.opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RotatedRaysBackground.displayName = "RotatedRaysBackground";
|
||||
|
||||
export default memo(RotatedRaysBackground);
|
||||
77
src/components/background/RotatingGradientBackground.tsx
Normal file
77
src/components/background/RotatingGradientBackground.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Sparkles } from './Sparkles';
|
||||
|
||||
interface RotatingGradientBackgroundProps {
|
||||
className?: string;
|
||||
gradientColorStart?: string;
|
||||
gradientColorEnd?: string;
|
||||
bigCircleSize?: string;
|
||||
smallCircleSize?: string;
|
||||
blurAmount?: string;
|
||||
opacity?: number;
|
||||
showSparkles?: boolean;
|
||||
}
|
||||
|
||||
const RotatingGradientBackground = ({
|
||||
className = "",
|
||||
gradientColorStart = "var(--color-background-accent)",
|
||||
gradientColorEnd = "var(--color-background-accent)",
|
||||
bigCircleSize = "28vw",
|
||||
smallCircleSize = "21vw",
|
||||
blurAmount = "10px",
|
||||
opacity = 0.6,
|
||||
showSparkles = true,
|
||||
}: RotatingGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full aspect-square animate-spin-slow opacity-75"
|
||||
style={{
|
||||
width: bigCircleSize,
|
||||
height: bigCircleSize,
|
||||
background: `linear-gradient(229deg, ${gradientColorStart} 10%, color-mix(in srgb, ${gradientColorStart} 0%, transparent) 40%, color-mix(in srgb, ${gradientColorEnd} 0%, transparent) 64%, ${gradientColorEnd} 88%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full aspect-square animate-spin-reverse opacity-75"
|
||||
style={{
|
||||
width: smallCircleSize,
|
||||
height: smallCircleSize,
|
||||
background: `linear-gradient(141deg, ${gradientColorStart} 13%, color-mix(in srgb, ${gradientColorStart} 0%, transparent) 37.5%, color-mix(in srgb, ${gradientColorEnd} 0%, transparent) 64%, ${gradientColorEnd} 88%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showSparkles && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
mask: 'radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 22%, rgb(0, 0, 0) 32%, rgb(0, 0, 0) 55%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0) 100%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)',
|
||||
maskComposite: 'intersect',
|
||||
WebkitMask: 'radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 22%, rgb(0, 0, 0) 32%, rgb(0, 0, 0) 55%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0) 100%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)',
|
||||
WebkitMaskComposite: 'source-in',
|
||||
}}
|
||||
>
|
||||
<Sparkles particleDensity={60} minSize={0.3} maxSize={0.8} speed={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RotatingGradientBackground.displayName = 'RotatingGradientBackground';
|
||||
|
||||
export default memo(RotatingGradientBackground);
|
||||
460
src/components/background/Sparkles.tsx
Normal file
460
src/components/background/Sparkles.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useEffect, useState } from "react";
|
||||
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||
import type { Container, SingleOrMultiple } from "@tsparticles/engine";
|
||||
import { loadSlim } from "@tsparticles/slim";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { motion, useAnimation } from "framer-motion";
|
||||
|
||||
type SparklesProps = {
|
||||
id?: string;
|
||||
className?: string;
|
||||
background?: string;
|
||||
particleSize?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
speed?: number;
|
||||
particleColor?: string;
|
||||
particleDensity?: number;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
minSize: 0.5,
|
||||
maxSize: 1,
|
||||
speed: 4,
|
||||
particleDensity: 100,
|
||||
particleColor: "var(--color-primary-cta)",
|
||||
background: "transparent",
|
||||
};
|
||||
|
||||
export const Sparkles = (props: SparklesProps) => {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
background = defaultProps.background,
|
||||
minSize = defaultProps.minSize,
|
||||
maxSize = defaultProps.maxSize,
|
||||
speed = defaultProps.speed,
|
||||
particleColor = defaultProps.particleColor,
|
||||
particleDensity = defaultProps.particleDensity,
|
||||
} = props;
|
||||
const [init, setInit] = useState(false);
|
||||
const [resolvedColor, setResolvedColor] = useState("#ffffff");
|
||||
|
||||
useEffect(() => {
|
||||
if (particleColor?.startsWith('var(')) {
|
||||
const varName = particleColor.match(/var\((.*?)\)/)?.[1];
|
||||
if (varName) {
|
||||
const computed = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (computed) {
|
||||
setResolvedColor(computed);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setResolvedColor(particleColor || "#ffffff");
|
||||
}
|
||||
}, [particleColor]);
|
||||
|
||||
useEffect(() => {
|
||||
initParticlesEngine(async (engine) => {
|
||||
await loadSlim(engine);
|
||||
}).then(() => {
|
||||
setInit(true);
|
||||
});
|
||||
}, []);
|
||||
const controls = useAnimation();
|
||||
|
||||
const particlesLoaded = async (container?: Container) => {
|
||||
if (container) {
|
||||
controls.start({
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const generatedId = useId();
|
||||
return (
|
||||
<motion.div animate={controls} className={cls("absolute inset-0 opacity-0", className)}>
|
||||
{init && (
|
||||
<Particles
|
||||
id={id || generatedId}
|
||||
className={cls("h-full w-full")}
|
||||
particlesLoaded={particlesLoaded}
|
||||
options={{
|
||||
background: {
|
||||
color: {
|
||||
value: background || "transparent",
|
||||
},
|
||||
},
|
||||
fullScreen: {
|
||||
enable: false,
|
||||
zIndex: 1,
|
||||
},
|
||||
|
||||
fpsLimit: 120,
|
||||
interactivity: {
|
||||
events: {
|
||||
onClick: {
|
||||
enable: true,
|
||||
mode: "push",
|
||||
},
|
||||
onHover: {
|
||||
enable: false,
|
||||
mode: "repulse",
|
||||
},
|
||||
resize: true as any,
|
||||
},
|
||||
modes: {
|
||||
push: {
|
||||
quantity: 4,
|
||||
},
|
||||
repulse: {
|
||||
distance: 200,
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
particles: {
|
||||
bounce: {
|
||||
horizontal: {
|
||||
value: 1,
|
||||
},
|
||||
vertical: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
collisions: {
|
||||
absorb: {
|
||||
speed: 2,
|
||||
},
|
||||
bounce: {
|
||||
horizontal: {
|
||||
value: 1,
|
||||
},
|
||||
vertical: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
enable: false,
|
||||
maxSpeed: 50,
|
||||
mode: "bounce",
|
||||
overlap: {
|
||||
enable: true,
|
||||
retries: 0,
|
||||
},
|
||||
},
|
||||
color: {
|
||||
value: resolvedColor,
|
||||
animation: {
|
||||
h: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: true,
|
||||
offset: 0,
|
||||
},
|
||||
s: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: true,
|
||||
offset: 0,
|
||||
},
|
||||
l: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: true,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
effect: {
|
||||
close: true,
|
||||
fill: true,
|
||||
options: {},
|
||||
type: {} as SingleOrMultiple<string> | undefined,
|
||||
},
|
||||
groups: {},
|
||||
move: {
|
||||
angle: {
|
||||
offset: 0,
|
||||
value: 90,
|
||||
},
|
||||
attract: {
|
||||
distance: 200,
|
||||
enable: false,
|
||||
rotate: {
|
||||
x: 3000,
|
||||
y: 3000,
|
||||
},
|
||||
},
|
||||
center: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
mode: "percent",
|
||||
radius: 0,
|
||||
},
|
||||
decay: 0,
|
||||
distance: {},
|
||||
direction: "none",
|
||||
drift: 0,
|
||||
enable: true,
|
||||
gravity: {
|
||||
acceleration: 9.81,
|
||||
enable: false,
|
||||
inverse: false,
|
||||
maxSpeed: 50,
|
||||
},
|
||||
path: {
|
||||
clamp: true,
|
||||
delay: {
|
||||
value: 0,
|
||||
},
|
||||
enable: false,
|
||||
options: {},
|
||||
},
|
||||
outModes: {
|
||||
default: "out",
|
||||
},
|
||||
random: false,
|
||||
size: false,
|
||||
speed: {
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
},
|
||||
spin: {
|
||||
acceleration: 0,
|
||||
enable: false,
|
||||
},
|
||||
straight: false,
|
||||
trail: {
|
||||
enable: false,
|
||||
length: 10,
|
||||
fill: {},
|
||||
},
|
||||
vibrate: false,
|
||||
warp: false,
|
||||
},
|
||||
number: {
|
||||
density: {
|
||||
enable: true,
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
limit: {
|
||||
mode: "delete",
|
||||
value: 0,
|
||||
},
|
||||
value: particleDensity || 120,
|
||||
},
|
||||
opacity: {
|
||||
value: {
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
},
|
||||
animation: {
|
||||
count: 0,
|
||||
enable: true,
|
||||
speed: speed || 4,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: false,
|
||||
mode: "auto",
|
||||
startValue: "random",
|
||||
destroy: "none",
|
||||
},
|
||||
},
|
||||
reduceDuplicates: false,
|
||||
shadow: {
|
||||
blur: 0,
|
||||
color: {
|
||||
value: "#000",
|
||||
},
|
||||
enable: false,
|
||||
offset: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
close: true,
|
||||
fill: true,
|
||||
options: {},
|
||||
type: "circle",
|
||||
},
|
||||
size: {
|
||||
value: {
|
||||
min: minSize || 1,
|
||||
max: maxSize || 3,
|
||||
},
|
||||
animation: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 5,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: false,
|
||||
mode: "auto",
|
||||
startValue: "random",
|
||||
destroy: "none",
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
width: 0,
|
||||
},
|
||||
zIndex: {
|
||||
value: 0,
|
||||
opacityRate: 1,
|
||||
sizeRate: 1,
|
||||
velocityRate: 1,
|
||||
},
|
||||
destroy: {
|
||||
bounds: {},
|
||||
mode: "none",
|
||||
split: {
|
||||
count: 1,
|
||||
factor: {
|
||||
value: 3,
|
||||
},
|
||||
rate: {
|
||||
value: {
|
||||
min: 4,
|
||||
max: 9,
|
||||
},
|
||||
},
|
||||
sizeOffset: true,
|
||||
},
|
||||
},
|
||||
roll: {
|
||||
darken: {
|
||||
enable: false,
|
||||
value: 0,
|
||||
},
|
||||
enable: false,
|
||||
enlighten: {
|
||||
enable: false,
|
||||
value: 0,
|
||||
},
|
||||
mode: "vertical",
|
||||
speed: 25,
|
||||
},
|
||||
tilt: {
|
||||
value: 0,
|
||||
animation: {
|
||||
enable: false,
|
||||
speed: 0,
|
||||
decay: 0,
|
||||
sync: false,
|
||||
},
|
||||
direction: "clockwise",
|
||||
enable: false,
|
||||
},
|
||||
twinkle: {
|
||||
lines: {
|
||||
enable: false,
|
||||
frequency: 0.05,
|
||||
opacity: 1,
|
||||
},
|
||||
particles: {
|
||||
enable: false,
|
||||
frequency: 0.05,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
wobble: {
|
||||
distance: 5,
|
||||
enable: false,
|
||||
speed: {
|
||||
angle: 50,
|
||||
move: 10,
|
||||
},
|
||||
},
|
||||
life: {
|
||||
count: 0,
|
||||
delay: {
|
||||
value: 0,
|
||||
sync: false,
|
||||
},
|
||||
duration: {
|
||||
value: 0,
|
||||
sync: false,
|
||||
},
|
||||
},
|
||||
rotate: {
|
||||
value: 0,
|
||||
animation: {
|
||||
enable: false,
|
||||
speed: 0,
|
||||
decay: 0,
|
||||
sync: false,
|
||||
},
|
||||
direction: "clockwise",
|
||||
path: false,
|
||||
},
|
||||
orbit: {
|
||||
animation: {
|
||||
count: 0,
|
||||
enable: false,
|
||||
speed: 1,
|
||||
decay: 0,
|
||||
delay: 0,
|
||||
sync: false,
|
||||
},
|
||||
enable: false,
|
||||
opacity: 1,
|
||||
rotation: {
|
||||
value: 45,
|
||||
},
|
||||
width: 1,
|
||||
},
|
||||
links: {
|
||||
blink: false,
|
||||
color: {
|
||||
value: "#fff",
|
||||
},
|
||||
consent: false,
|
||||
distance: 100,
|
||||
enable: false,
|
||||
frequency: 1,
|
||||
opacity: 1,
|
||||
shadow: {
|
||||
blur: 5,
|
||||
color: {
|
||||
value: "#000",
|
||||
},
|
||||
enable: false,
|
||||
},
|
||||
triangles: {
|
||||
enable: false,
|
||||
frequency: 1,
|
||||
},
|
||||
width: 1,
|
||||
warp: false,
|
||||
},
|
||||
repulse: {
|
||||
value: 0,
|
||||
enabled: false,
|
||||
distance: 1,
|
||||
duration: 1,
|
||||
factor: 1,
|
||||
speed: 1,
|
||||
},
|
||||
},
|
||||
detectRetina: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
56
src/components/background/SparklesGradientBackground.tsx
Normal file
56
src/components/background/SparklesGradientBackground.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { cls } from '@/lib/utils';
|
||||
import { Sparkles } from './Sparkles';
|
||||
|
||||
interface SparklesGradientBackgroundProps {
|
||||
className?: string;
|
||||
gradientColor?: string;
|
||||
accentColor?: string;
|
||||
blurAmount?: string;
|
||||
}
|
||||
|
||||
const SparklesGradientBackground = ({
|
||||
className = "",
|
||||
gradientColor = "var(--color-background-accent)",
|
||||
accentColor = "var(--color-background-accent)",
|
||||
blurAmount = "6vw",
|
||||
}: SparklesGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
|
||||
style={{
|
||||
mask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
WebkitMask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 w-[65vw] h-[88vh] -top-[59vh] overflow-visible z-0"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[100%] overflow-hidden"
|
||||
style={{
|
||||
background: `radial-gradient(50% 50% at 50% 50%, ${gradientColor}, color-mix(in srgb, ${gradientColor} 25%, transparent) 41%, color-mix(in srgb, ${gradientColor} 20%, transparent))`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[33vw] h-[53vh] rounded-[100%] overflow-hidden"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${accentColor} 30%, transparent)`,
|
||||
filter: `blur(${blurAmount})`,
|
||||
WebkitFilter: `blur(${blurAmount})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Sparkles />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SparklesGradientBackground.displayName = 'SparklesGradientBackground';
|
||||
|
||||
export default memo(SparklesGradientBackground);
|
||||
@@ -0,0 +1,102 @@
|
||||
.floating-gradient-background-container {
|
||||
--circle-size: 80%;
|
||||
--circle-size-small: 60%;
|
||||
--blending: hard-light;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-one {
|
||||
background: radial-gradient(circle at center, var(--color-background-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size);
|
||||
height: var(--circle-size);
|
||||
top: calc(50% - var(--circle-size-small) / 2);
|
||||
left: calc(50% - var(--circle-size-small) / 2);
|
||||
transform-origin: center center;
|
||||
animation: moveVertical 20s ease infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-two {
|
||||
background: radial-gradient(circle at center, var(--color-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size);
|
||||
height: var(--circle-size);
|
||||
top: calc(50% - var(--circle-size-small) / 2);
|
||||
left: calc(50% - var(--circle-size-small) / 2);
|
||||
transform-origin: calc(50% - 400px);
|
||||
animation: moveInCircle 20s reverse infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-three {
|
||||
background: radial-gradient(circle at center, var(--color-primary-cta) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size-small);
|
||||
height: var(--circle-size-small);
|
||||
top: calc(50% - var(--circle-size) / 2 + 200px);
|
||||
left: calc(50% - var(--circle-size) / 2 - 500px);
|
||||
transform-origin: calc(50% + 400px);
|
||||
animation: moveInCircle 30s linear infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-four {
|
||||
background: radial-gradient(circle at center, var(--color-background-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: var(--circle-size-small);
|
||||
height: var(--circle-size-small);
|
||||
top: calc(50% - var(--circle-size) / 2);
|
||||
left: calc(50% - var(--circle-size) / 2);
|
||||
transform-origin: calc(50% - 200px);
|
||||
animation: moveHorizontal 30s ease infinite;
|
||||
}
|
||||
|
||||
.floating-gradient-background-circle-five {
|
||||
background: radial-gradient(circle at center, var(--color-primary-cta) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
|
||||
mix-blend-mode: var(--blending);
|
||||
width: calc(var(--circle-size-small) * 2);
|
||||
height: calc(var(--circle-size-small) * 2);
|
||||
top: calc(50% - var(--circle-size));
|
||||
left: calc(50% - var(--circle-size));
|
||||
transform-origin: calc(50% - 800px) calc(50% + 200px);
|
||||
animation: moveInCircle 20s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes moveInCircle {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveVertical {
|
||||
0% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveHorizontal {
|
||||
0% {
|
||||
transform: translateX(-50%) translateY(-10%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(50%) translateY(10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(-10%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./FloatingGradientBackground.css";
|
||||
|
||||
interface FloatingGradientBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FloatingGradientBackground = ({
|
||||
className = "",
|
||||
}: FloatingGradientBackgroundProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"fixed top-0 bottom-0 left-0 right-0 w-full h-full z-0 pointer-events-none blur-[40px]",
|
||||
"[mask-image:linear-gradient(to_bottom,transparent,#010101_20%,#010101_80%,transparent)]",
|
||||
"[mask-composite:intersect]",
|
||||
"[-webkit-mask-image:linear-gradient(to_bottom,transparent,#010101_20%,#010101_80%,transparent)]",
|
||||
"[-webkit-mask-composite:destination-in]",
|
||||
"floating-gradient-background-container",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute opacity-[0.075] floating-gradient-background-circle-one" />
|
||||
<div className="absolute opacity-[0.125] floating-gradient-background-circle-two" />
|
||||
<div className="absolute opacity-[0.125] floating-gradient-background-circle-three" />
|
||||
<div className="absolute opacity-[0.15] floating-gradient-background-circle-four" />
|
||||
<div className="absolute opacity-[0.075] floating-gradient-background-circle-five" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FloatingGradientBackground.displayName = "FloatingGradientBackground";
|
||||
|
||||
export default memo(FloatingGradientBackground);
|
||||
122
src/components/bento/Bento3DCardGrid.tsx
Normal file
122
src/components/bento/Bento3DCardGrid.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type GridCardItem = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
interface Bento3DCardGridProps {
|
||||
useInvertedBackground: InvertedBackground;
|
||||
items: [GridCardItem, GridCardItem, GridCardItem, GridCardItem];
|
||||
centerIcon: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const gridItemStyle = {
|
||||
perspective: '1000px',
|
||||
transformStyle: 'preserve-3d' as const,
|
||||
};
|
||||
|
||||
const EmptyCell = () => (
|
||||
<div
|
||||
className="relative aspect-square card shadow rounded-theme-capped opacity-50"
|
||||
style={gridItemStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTranslateZ = [
|
||||
'group-hover:[transform:translateZ(10px)]',
|
||||
'group-hover:[transform:translateZ(14px)]',
|
||||
'group-hover:[transform:translateZ(18px)]',
|
||||
'group-hover:[transform:translateZ(22px)]',
|
||||
] as const;
|
||||
|
||||
const CardCell = ({ name, Icon, cardIndex }: { name: string; Icon: LucideIcon; cardIndex: number }) => (
|
||||
<div
|
||||
className={cls(
|
||||
"relative card shadow aspect-square rounded-theme-capped flex flex-col justify-between p-3 transition-transform duration-500",
|
||||
cardTranslateZ[cardIndex]
|
||||
)}
|
||||
style={gridItemStyle}
|
||||
>
|
||||
<div className="h-6 w-[var(--height-6)] aspect-square rounded-theme primary-button flex items-center justify-center">
|
||||
<Icon className="h-4/10 w-4/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="text-xs text-foreground leading-tight line-clamp-4">{name}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CenterCell = ({ Icon }: { Icon: LucideIcon }) => (
|
||||
<div
|
||||
className="aspect-square flex items-center justify-center bg-transparent border-none overflow-visible"
|
||||
style={gridItemStyle}
|
||||
>
|
||||
<div className="card shadow rounded-full h-6/10 aspect-square flex items-center justify-center">
|
||||
<Icon className="h-4/10 w-4/10 text-foreground" strokeWidth={1.25} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Bento3DCardGrid = ({
|
||||
useInvertedBackground,
|
||||
items,
|
||||
centerIcon: CenterIcon,
|
||||
className = "",
|
||||
}: Bento3DCardGridProps) => {
|
||||
void useInvertedBackground;
|
||||
|
||||
const gridPositions = [
|
||||
{ type: 'empty' },
|
||||
{ type: 'card', index: 0 },
|
||||
{ type: 'empty' },
|
||||
{ type: 'card', index: 1 },
|
||||
{ type: 'center' },
|
||||
{ type: 'card', index: 2 },
|
||||
{ type: 'empty' },
|
||||
{ type: 'card', index: 3 },
|
||||
{ type: 'empty' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("group w-full h-full", className)}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%)',
|
||||
maskComposite: 'intersect',
|
||||
WebkitMaskImage: 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%)',
|
||||
WebkitMaskComposite: 'source-in',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full grid grid-cols-3 gap-4 -translate-y-9 -translate-x-8"
|
||||
style={{
|
||||
gridAutoRows: '1fr',
|
||||
perspective: '5000px',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: 'rotateX(45deg) rotateY(20deg) rotate(-25deg) scale(1.1)',
|
||||
}}
|
||||
>
|
||||
{gridPositions.map((pos, index) => {
|
||||
switch (pos.type) {
|
||||
case 'card':
|
||||
const item = items[pos.index];
|
||||
return <CardCell key={index} name={item.name} Icon={item.icon} cardIndex={pos.index} />;
|
||||
case 'center':
|
||||
return <CenterCell key={index} Icon={CenterIcon} />;
|
||||
default:
|
||||
return <EmptyCell key={index} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Bento3DCardGrid.displayName = "Bento3DCardGrid";
|
||||
|
||||
export default memo(Bento3DCardGrid);
|
||||
117
src/components/bento/Bento3DStackCards.tsx
Normal file
117
src/components/bento/Bento3DStackCards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface StackCardProps {
|
||||
Icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
detail: string;
|
||||
iconClassName?: string;
|
||||
titleClassName?: string;
|
||||
subtitleClassName?: string;
|
||||
detailClassName?: string;
|
||||
}
|
||||
|
||||
interface Bento3DStackCardProps extends StackCardProps {
|
||||
className?: string;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
}
|
||||
|
||||
const StackCard = memo(({
|
||||
className = "",
|
||||
Icon,
|
||||
title,
|
||||
subtitle,
|
||||
detail,
|
||||
iconClassName = "",
|
||||
titleClassName = "",
|
||||
subtitleClassName = "",
|
||||
detailClassName = "",
|
||||
useInvertedBackground,
|
||||
}: Bento3DStackCardProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"relative flex h-35 w-80 md:w-25 p-6 -skew-y-[8deg] card rounded-theme-capped flex-col justify-between transition-all duration-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cls(
|
||||
"relative h-5 aspect-square primary-button rounded-theme flex items-center justify-center",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<Icon className="h-1/2 w-auto aspect-square text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("whitespace-nowrap text-lg", shouldUseLightText ? "text-background" : "text-foreground", subtitleClassName)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", detailClassName)}>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StackCard.displayName = "StackCard";
|
||||
|
||||
interface Bento3DStackCardsProps {
|
||||
cards: StackCardProps[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Bento3DStackCards = ({
|
||||
cards,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: Bento3DStackCardsProps) => {
|
||||
const baseClassNames = [
|
||||
"[grid-area:stack] -translate-y-14 hover:-translate-y-20",
|
||||
"[grid-area:stack] translate-x-15 translate-y-0 hover:-translate-y-5",
|
||||
"[grid-area:stack] translate-x-31 translate-y-15 hover:translate-y-10",
|
||||
];
|
||||
|
||||
const displayCards = cards.slice(0, 3).map((card, index) => ({
|
||||
...card,
|
||||
className: `${baseClassNames[index]} ${card.iconClassName || ""}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("h-full grid [grid-template-areas:'stack'] place-items-center opacity-100 animate-in fade-in-0 duration-700", className)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, black 0%, black 80%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, black 0%, black 80%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in"
|
||||
}}
|
||||
>
|
||||
{displayCards.map((cardProps, index) => (
|
||||
<StackCard
|
||||
key={index}
|
||||
{...cardProps}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Bento3DStackCards.displayName = "Bento3DStackCards";
|
||||
|
||||
export default memo(Bento3DStackCards);
|
||||
97
src/components/bento/Bento3DTaskList.tsx
Normal file
97
src/components/bento/Bento3DTaskList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Fragment } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type TaskItem = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
interface Bento3DTaskListProps {
|
||||
title: string;
|
||||
items: TaskItem[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Bento3DTaskList = ({
|
||||
title,
|
||||
items,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: Bento3DTaskListProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("h-full w-full flex items-center justify-center", className)}
|
||||
style={{
|
||||
perspective: "1200px",
|
||||
transformStyle: "preserve-3d",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative w-80 md:w-25 p-6 card rounded-theme-capped flex flex-col gap-3 translate-x-4 -translate-y-5"
|
||||
)}
|
||||
style={{
|
||||
transform: "rotateX(30deg) rotateY(30deg) rotateZ(-30deg)",
|
||||
transformStyle: "preserve-3d"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[var(--text-base)] w-auto aspect-square rounded-theme primary-button" />
|
||||
<h3 className={cls("text-base leading-tight", shouldUseLightText ? "text-background" : "text-foreground")}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full min-w-0 secondary-button rounded-theme-capped flex flex-col p-5 gap-3">
|
||||
{items.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div
|
||||
className={cls(
|
||||
"w-full min-w-0 flex items-center justify-between gap-3"
|
||||
)}
|
||||
>
|
||||
<div className="w-full min-w-0 flex items-center gap-3">
|
||||
<div
|
||||
className="h-6 w-auto aspect-square rounded-theme flex items-center justify-center primary-button"
|
||||
>
|
||||
<Icon className="h-4/10 w-4/10 aspect-square text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className={cls("text-sm truncate", shouldUseLightText ? "text-background" : "text-foreground")}>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("text-xs text-nowrap", shouldUseLightText ? "text-background/75" : "text-foreground/75")}>
|
||||
{item.time}
|
||||
</p>
|
||||
</div>
|
||||
{index !== items.length - 1 && (
|
||||
<div className="h-px bg-background-accent/50" />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Bento3DTaskList.displayName = "Bento3DTaskList";
|
||||
|
||||
export default memo(Bento3DTaskList);
|
||||
77
src/components/bento/BentoAnimatedBarChart.tsx
Normal file
77
src/components/bento/BentoAnimatedBarChart.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type BarData = {
|
||||
defaultHeight: number;
|
||||
hoverHeight: number;
|
||||
};
|
||||
|
||||
interface BentoAnimatedBarChartProps {
|
||||
bars?: BarData[];
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
}
|
||||
|
||||
const defaultBars: BarData[] = [
|
||||
{ defaultHeight: 100, hoverHeight: 40 },
|
||||
{ defaultHeight: 84, hoverHeight: 100 },
|
||||
{ defaultHeight: 62, hoverHeight: 75 },
|
||||
{ defaultHeight: 90, hoverHeight: 50 },
|
||||
{ defaultHeight: 70, hoverHeight: 90 },
|
||||
{ defaultHeight: 50, hoverHeight: 60 },
|
||||
{ defaultHeight: 75, hoverHeight: 85 },
|
||||
{ defaultHeight: 80, hoverHeight: 70 },
|
||||
];
|
||||
|
||||
const BentoAnimatedBarChart = ({
|
||||
bars = defaultBars,
|
||||
className = "",
|
||||
barClassName = "",
|
||||
}: BentoAnimatedBarChartProps) => {
|
||||
const [activeBar, setActiveBar] = useState(2); // Start at third bar (index 2)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveBar((prev) => (prev + 1) % bars.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [bars.length]);
|
||||
|
||||
return (
|
||||
<div className={cls("group w-full h-full [mask-image:linear-gradient(to_bottom,black_40%,transparent_100%)]", className)}>
|
||||
<style>{`
|
||||
.bento-bar {
|
||||
height: var(--default-height);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.group:hover .bento-bar {
|
||||
height: var(--hover-height) !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="w-full h-full flex items-end gap-5">
|
||||
{bars.map((bar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("relative bento-bar w-full rounded-theme transition-all duration-500 ease bg-background-accent", barClassName)}
|
||||
style={
|
||||
{
|
||||
"--default-height": `${bar.defaultHeight}%`,
|
||||
"--hover-height": `${bar.hoverHeight}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={cls("absolute! inset-0 primary-button rounded-theme transition-opacity ease-in-out duration-500", activeBar === index ? "opacity-100" : "opacity-0")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoAnimatedBarChart.displayName = "BentoAnimatedBarChart";
|
||||
|
||||
export default memo(BentoAnimatedBarChart);
|
||||
96
src/components/bento/BentoChatAnimation.tsx
Normal file
96
src/components/bento/BentoChatAnimation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Send } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type ChatExchange = {
|
||||
userMessage: string;
|
||||
aiResponse: string;
|
||||
};
|
||||
|
||||
interface BentoChatAnimationProps {
|
||||
aiIcon: LucideIcon;
|
||||
userIcon: LucideIcon;
|
||||
exchanges: ChatExchange[];
|
||||
placeholder: string;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoChatAnimation = ({
|
||||
aiIcon: AiIcon,
|
||||
userIcon: UserIcon,
|
||||
exchanges,
|
||||
placeholder,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoChatAnimationProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const messages = exchanges.flatMap((exchange) => [
|
||||
{ content: exchange.userMessage, isUser: true },
|
||||
{ content: exchange.aiResponse, isUser: false },
|
||||
]);
|
||||
const duplicatedMessages = [...messages, ...messages];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"relative h-full w-full flex flex-col overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col animate-marquee-vertical px-4">
|
||||
{duplicatedMessages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"flex items-end gap-2 shrink-0 mb-4",
|
||||
message.isUser ? "flex-row-reverse" : "flex-row"
|
||||
)}
|
||||
>
|
||||
{message.isUser ? (
|
||||
<div className="shrink-0 h-8 aspect-square rounded-theme primary-button flex items-center justify-center">
|
||||
<UserIcon className="h-4/10 w-auto text-primary-cta-text" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 h-8 aspect-square rounded-theme card shadow flex items-center justify-center">
|
||||
<AiIcon className={cls("h-4/10 w-auto", shouldUseLightText ? "text-background" : "text-foreground")} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cls(
|
||||
"max-w-75/100 px-4 py-3 text-sm leading-tight",
|
||||
message.isUser
|
||||
? "primary-button rounded-theme-capped rounded-br-none text-primary-cta-text"
|
||||
: "card rounded-theme-capped rounded-bl-none",
|
||||
!message.isUser && (shouldUseLightText ? "text-background" : "text-foreground")
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow rounded-theme p-2 pl-5 flex items-center gap-2">
|
||||
<p className={cls("flex-1 text-sm truncate", shouldUseLightText ? "text-background/75" : "text-foreground/75")}>
|
||||
{placeholder}
|
||||
</p>
|
||||
<div className="h-7 w-auto aspect-square primary-button rounded-theme flex items-center justify-center">
|
||||
<Send className="h-4/10 w-auto text-primary-cta-text" strokeWidth={1.75} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoChatAnimation.displayName = "BentoChatAnimation";
|
||||
|
||||
export default memo(BentoChatAnimation);
|
||||
204
src/components/bento/BentoGlobe.tsx
Normal file
204
src/components/bento/BentoGlobe.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import createGlobe, { COBEOptions } from "cobe";
|
||||
|
||||
// Helper function to convert CSS color to RGB array
|
||||
const getRGBFromCSSVar = (varName: string): [number, number, number] => {
|
||||
if (typeof window === "undefined") return [0.5, 0.5, 0.5];
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
|
||||
// Handle CSS named colors by creating a temporary element to get computed RGB
|
||||
if (value && !value.startsWith("#") && !value.startsWith("rgb") && !value.includes("%") && !value.match(/^\d+\s+\d+\s+\d+$/)) {
|
||||
const temp = document.createElement("div");
|
||||
temp.style.color = value;
|
||||
document.body.appendChild(temp);
|
||||
const computed = getComputedStyle(temp).color;
|
||||
document.body.removeChild(temp);
|
||||
|
||||
if (computed && computed.startsWith("rgb")) {
|
||||
const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
const r = parseInt(match[1]) / 255;
|
||||
const g = parseInt(match[2]) / 255;
|
||||
const b = parseInt(match[3]) / 255;
|
||||
return [r, g, b];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle rgba/rgb format (e.g., "rgba(18, 0, 6, .9)" or "rgb(255, 255, 255)")
|
||||
if (value.startsWith("rgb")) {
|
||||
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
const r = parseInt(match[1]) / 255;
|
||||
const g = parseInt(match[2]) / 255;
|
||||
const b = parseInt(match[3]) / 255;
|
||||
return [r, g, b];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hex format (e.g., "#ffffff", "#ffffffaa", or shorthand "#fff", "#f0f")
|
||||
if (value.startsWith("#")) {
|
||||
let hex = value.replace("#", "");
|
||||
// Expand shorthand hex (e.g., "93f" -> "9933ff")
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
hex = hex.split("").map(c => c + c).join("").substring(0, 6);
|
||||
}
|
||||
// Take only first 6 characters (ignore alpha channel if present)
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
// Handle HSL format (e.g., "0 0% 100%")
|
||||
if (value.includes("%")) {
|
||||
const [h, s, l] = value.split(/\s+/).map(v => parseFloat(v));
|
||||
// Convert HSL to RGB
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
return [(r + m), (g + m), (b + m)];
|
||||
}
|
||||
|
||||
// Handle RGB format (e.g., "255 255 255")
|
||||
const [r, g, b] = value.split(/\s+/).map(v => parseFloat(v) / 255);
|
||||
return [r || 0.5, g || 0.5, b || 0.5];
|
||||
};
|
||||
|
||||
const getGlobeConfig = (): COBEOptions => ({
|
||||
width: 800,
|
||||
height: 800,
|
||||
onRender: () => {},
|
||||
devicePixelRatio: 2,
|
||||
phi: 0,
|
||||
theta: 0.3,
|
||||
dark: 0,
|
||||
diffuse: 0.4,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 1.2,
|
||||
baseColor: getRGBFromCSSVar("--secondary-cta"),
|
||||
markerColor: getRGBFromCSSVar("--primary-cta"),
|
||||
glowColor: getRGBFromCSSVar("--card"),
|
||||
markers: [
|
||||
{ location: [14.5995, 120.9842], size: 0.03 },
|
||||
{ location: [19.076, 72.8777], size: 0.1 },
|
||||
{ location: [23.8103, 90.4125], size: 0.05 },
|
||||
{ location: [30.0444, 31.2357], size: 0.07 },
|
||||
{ location: [39.9042, 116.4074], size: 0.08 },
|
||||
{ location: [-23.5505, -46.6333], size: 0.1 },
|
||||
{ location: [19.4326, -99.1332], size: 0.1 },
|
||||
{ location: [40.7128, -74.006], size: 0.1 },
|
||||
{ location: [34.6937, 135.5022], size: 0.05 },
|
||||
{ location: [41.0082, 28.9784], size: 0.06 },
|
||||
],
|
||||
});
|
||||
|
||||
interface GlobeProps {
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
}
|
||||
|
||||
const GlobeComponent = ({
|
||||
className = "",
|
||||
config,
|
||||
}: GlobeProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const globeRef = useRef<{ destroy: () => void } | null>(null);
|
||||
const phiRef = useRef(0);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [globeConfig, setGlobeConfig] = useState<COBEOptions | null>(null);
|
||||
|
||||
const onRender = useCallback(
|
||||
(state: Record<string, number>) => {
|
||||
phiRef.current += 0.005;
|
||||
state.phi = phiRef.current;
|
||||
state.width = dimensions.width * 2;
|
||||
state.height = dimensions.width * 2;
|
||||
},
|
||||
[dimensions]
|
||||
);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (canvasRef.current) {
|
||||
const newWidth = canvasRef.current.offsetWidth;
|
||||
setDimensions(prev => {
|
||||
if (prev.width === newWidth) return prev;
|
||||
return { width: newWidth, height: newWidth };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [onResize]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize globe config with CSS variables
|
||||
const defaultConfig = getGlobeConfig();
|
||||
setGlobeConfig(config ? { ...defaultConfig, ...config } : defaultConfig);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || dimensions.width === 0 || !globeConfig) return;
|
||||
|
||||
if (globeRef.current) {
|
||||
globeRef.current.destroy();
|
||||
}
|
||||
|
||||
globeRef.current = createGlobe(canvasRef.current, {
|
||||
...globeConfig,
|
||||
width: dimensions.width * 2,
|
||||
height: dimensions.width * 2,
|
||||
onRender,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (globeRef.current) {
|
||||
globeRef.current.destroy();
|
||||
globeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dimensions, globeConfig, onRender]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-0 mx-auto w-full aspect-square",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
className="size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
|
||||
ref={canvasRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GlobeComponent.displayName = "BentoGlobe";
|
||||
|
||||
export const BentoGlobe = React.memo(GlobeComponent);
|
||||
72
src/components/bento/BentoIconInfoCards.tsx
Normal file
72
src/components/bento/BentoIconInfoCards.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type BentoInfoItem = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface BentoIconInfoCardsProps {
|
||||
items: BentoInfoItem[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
cardClassName?: string;
|
||||
iconWrapperClassName?: string;
|
||||
iconClassName?: string;
|
||||
labelClassName?: string;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
const BentoIconInfoCards = ({
|
||||
items,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
cardClassName = "",
|
||||
iconWrapperClassName = "",
|
||||
iconClassName = "",
|
||||
labelClassName = "",
|
||||
valueClassName = "",
|
||||
}: BentoIconInfoCardsProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const duplicatedItems = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<div className={cls("h-full min-h-0 overflow-hidden mask-fade-y", className)}>
|
||||
<div className="flex flex-col animate-marquee-vertical px-px">
|
||||
{duplicatedItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("card shadow rounded-theme-capped p-3 flex items-center justify-between flex-shrink-0 mb-4", cardClassName)}
|
||||
>
|
||||
<div className="w-full min-w-0 flex items-center gap-3">
|
||||
<div className={cls("h-10 w-auto aspect-square rounded-theme flex items-center justify-center secondary-button", iconWrapperClassName)}>
|
||||
<Icon className={cls("h-4/10 w-4/10 text-secondary-cta-text", iconClassName)} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className={cls("text-base truncate", shouldUseLightText ? "text-background" : "text-foreground", labelClassName)}>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", valueClassName)}>
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoIconInfoCards.displayName = "BentoIconInfoCards";
|
||||
|
||||
export default memo(BentoIconInfoCards);
|
||||
145
src/components/bento/BentoLineChart/BentoLineChart.tsx
Normal file
145
src/components/bento/BentoLineChart/BentoLineChart.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { formatNumber, calculateYAxisWidth, type ChartDataItem } from "./utils";
|
||||
import CustomTooltip from "./CustomTooltip";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface BentoLineChartProps {
|
||||
data?: ChartDataItem[];
|
||||
dataKey?: string;
|
||||
metricLabel?: string;
|
||||
isPercentage?: boolean;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultData: ChartDataItem[] = [
|
||||
{ value: 120 },
|
||||
{ value: 180 },
|
||||
{ value: 150 },
|
||||
{ value: 280 },
|
||||
{ value: 220 },
|
||||
{ value: 350 },
|
||||
{ value: 300 },
|
||||
{ value: 250 },
|
||||
];
|
||||
|
||||
const BentoLineChart = memo<BentoLineChartProps>(
|
||||
({
|
||||
data = defaultData,
|
||||
dataKey = "value",
|
||||
metricLabel = "Value",
|
||||
isPercentage = false,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
const yAxisWidth = calculateYAxisWidth(data, isPercentage);
|
||||
|
||||
const strokeColor = "var(--primary-cta)";
|
||||
const gridColor = "color-mix(in srgb, var(--background-accent) 30%, transparent)";
|
||||
const tickColor = shouldUseLightText ? "var(--background)" : "var(--foreground)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("w-full h-full **:outline-none **:focus:outline-none", className)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, black 40%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, black 40%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 14,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="bentoLineChartFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="bentoFadeGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="black" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="black" stopOpacity={0} />
|
||||
<stop offset="15%" stopColor="white" stopOpacity={1} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={1} />
|
||||
<stop offset="100%" stopColor="black" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<mask id="bentoFadeMask">
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#bentoFadeGradient)"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{
|
||||
fill: tickColor,
|
||||
fontSize: 10,
|
||||
}}
|
||||
width={yAxisWidth}
|
||||
tickFormatter={(value) =>
|
||||
isPercentage ? `${value}%` : formatNumber(value)
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip
|
||||
metricLabel={metricLabel}
|
||||
isPercentage={isPercentage}
|
||||
totalItems={data.length}
|
||||
/>
|
||||
}
|
||||
cursor={{
|
||||
stroke: gridColor,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
type="monotone"
|
||||
fill="url(#bentoLineChartFill)"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2}
|
||||
mask="url(#bentoFadeMask)"
|
||||
activeDot={{
|
||||
fill: strokeColor,
|
||||
r: 5,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BentoLineChart.displayName = "BentoLineChart";
|
||||
|
||||
export default BentoLineChart;
|
||||
64
src/components/bento/BentoLineChart/CustomTooltip.tsx
Normal file
64
src/components/bento/BentoLineChart/CustomTooltip.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { formatNumber } from "./utils";
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
color: string;
|
||||
}>;
|
||||
label?: number;
|
||||
metricLabel?: string;
|
||||
isPercentage?: boolean;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
const CustomTooltip = memo<CustomTooltipProps>(
|
||||
({
|
||||
active,
|
||||
payload,
|
||||
label = 0,
|
||||
metricLabel = "Value",
|
||||
isPercentage = false,
|
||||
totalItems,
|
||||
}: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = isPercentage
|
||||
? `${payload[0].value}%`
|
||||
: formatNumber(payload[0].value);
|
||||
const today = new Date();
|
||||
const daysAgo = totalItems - 1 - label;
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - daysAgo);
|
||||
return (
|
||||
<div className="card rounded-theme-capped p-3">
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 aspect-square rounded-full"
|
||||
style={{
|
||||
backgroundColor: payload[0].color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-foreground">
|
||||
{metricLabel}: {value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
|
||||
export default CustomTooltip;
|
||||
33
src/components/bento/BentoLineChart/utils.ts
Normal file
33
src/components/bento/BentoLineChart/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const formatNumber = (value: number): string => {
|
||||
if (value >= 100000) {
|
||||
const millions = value / 1000000;
|
||||
return `${millions.toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
const thousands = value / 1000;
|
||||
const rounded = Math.round(thousands * 10) / 10;
|
||||
return `${rounded}K`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
export interface ChartDataItem {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const calculateYAxisWidth = (
|
||||
data: ChartDataItem[],
|
||||
isPercentage: boolean
|
||||
): number => {
|
||||
const maxValue = Math.max(...data.map((item) => item.value));
|
||||
const formattedMax = isPercentage ? `${maxValue}%` : formatNumber(maxValue);
|
||||
|
||||
let multiplier = 9;
|
||||
if (formattedMax.length === 2) {
|
||||
multiplier = 11;
|
||||
} else if (formattedMax.length === 3) {
|
||||
multiplier = 13;
|
||||
}
|
||||
|
||||
return formattedMax.length * multiplier;
|
||||
};
|
||||
1460
src/components/bento/BentoMap.tsx
Normal file
1460
src/components/bento/BentoMap.tsx
Normal file
File diff suppressed because it is too large
Load Diff
71
src/components/bento/BentoMarquee.tsx
Normal file
71
src/components/bento/BentoMarquee.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Marquee from "react-fast-marquee";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type BentoMarqueeProps = {
|
||||
centerIcon: LucideIcon;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
} & (
|
||||
| { variant: "text"; texts: string[] }
|
||||
| { variant: "icon"; icons: LucideIcon[] }
|
||||
);
|
||||
|
||||
const BentoMarquee = (props: BentoMarqueeProps) => {
|
||||
const { centerIcon, useInvertedBackground, className = "" } = props;
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
const CenterIcon = centerIcon;
|
||||
const items = props.variant === "text"
|
||||
? [...props.texts, ...props.texts]
|
||||
: [...props.icons, ...props.icons];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("relative h-full w-full flex flex-col overflow-hidden", className)}
|
||||
style={{
|
||||
maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)"
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-1/2 h-auto w-full flex flex-col justify-center gap-2 opacity-60">
|
||||
{Array.from({ length: 10 }).map((_, rowIndex) => (
|
||||
<Marquee
|
||||
key={rowIndex}
|
||||
gradient={false}
|
||||
speed={10}
|
||||
direction={rowIndex % 2 === 0 ? "left" : "right"}
|
||||
>
|
||||
{items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className={cls("relative mx-1 card rounded-theme flex items-center justify-center", props.variant === "icon" ? "p-2 aspect-square" : "px-4 py-2")}
|
||||
>
|
||||
{props.variant === "text" ? (
|
||||
<p className={cls("text-sm leading-tight", shouldUseLightText ? "text-background" : "text-foreground")}>{item as string}</p>
|
||||
) : (
|
||||
(() => {
|
||||
const Icon = item as LucideIcon;
|
||||
return <Icon className={cls("h-1/2 w-1/2", shouldUseLightText ? "text-background" : "text-foreground")} strokeWidth={1.5} />;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute! top-1/2 left-1/2 -translate-1/2 z-10 h-18 w-auto aspect-square primary-button backdrop-blur-xs rounded-theme flex items-center justify-center">
|
||||
<CenterIcon className="h-4/10 w-4/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoMarquee.displayName = "BentoMarquee";
|
||||
|
||||
export default memo(BentoMarquee);
|
||||
82
src/components/bento/BentoMediaStack.tsx
Normal file
82
src/components/bento/BentoMediaStack.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import MediaContent from "@/components/shared/MediaContent";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type MediaStackItem = {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
imageAlt?: string;
|
||||
};
|
||||
|
||||
interface BentoMediaStackProps {
|
||||
items: [MediaStackItem, MediaStackItem, MediaStackItem];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoMediaStack = ({
|
||||
items,
|
||||
className = "",
|
||||
}: BentoMediaStackProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls("group/stack relative w-full h-full card shadow rounded-theme-capped flex items-center justify-center select-none", className)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-[1]",
|
||||
"rotate-8 translate-x-[12%] -translate-y-[8%] transition-all duration-500 ease-out",
|
||||
"2xl:translate-x-[8%] 2xl:-translate-y-[6%]",
|
||||
"group-hover/stack:translate-x-[22%] group-hover/stack:rotate-12 group-hover/stack:-translate-y-[14%]",
|
||||
"2xl:group-hover/stack:translate-x-[16%] 2xl:group-hover/stack:-translate-y-[10%]"
|
||||
)}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={items[2].imageSrc}
|
||||
videoSrc={items[2].videoSrc}
|
||||
imageAlt={items[2].imageAlt}
|
||||
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-[2]",
|
||||
"-rotate-8 -translate-x-[12%] -translate-y-[8%] transition-all duration-500 ease-out",
|
||||
"2xl:-translate-x-[8%] 2xl:-translate-y-[6%]",
|
||||
"group-hover/stack:-translate-x-[22%] group-hover/stack:-rotate-12 group-hover/stack:-translate-y-[14%]",
|
||||
"2xl:group-hover/stack:-translate-x-[16%] 2xl:group-hover/stack:-translate-y-[10%]"
|
||||
)}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={items[1].imageSrc}
|
||||
videoSrc={items[1].videoSrc}
|
||||
imageAlt={items[1].imageAlt}
|
||||
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-30",
|
||||
"translate-y-[10%] transition-all duration-500 ease-out",
|
||||
"2xl:translate-y-[7%]",
|
||||
"group-hover/stack:translate-y-[20%]",
|
||||
"2xl:group-hover/stack:translate-y-[14%]"
|
||||
)}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={items[0].imageSrc}
|
||||
videoSrc={items[0].videoSrc}
|
||||
imageAlt={items[0].imageAlt}
|
||||
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoMediaStack.displayName = "BentoMediaStack";
|
||||
|
||||
export default memo(BentoMediaStack);
|
||||
104
src/components/bento/BentoOrbitingIcons.tsx
Normal file
104
src/components/bento/BentoOrbitingIcons.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type OrbitingItem = {
|
||||
icon: LucideIcon;
|
||||
ring?: 1 | 2 | 3; // Which ring to orbit on (1=innermost, 3=outermost), defaults to 2
|
||||
duration?: number; // Animation duration in seconds, defaults to 10
|
||||
};
|
||||
|
||||
interface BentoOrbitingIconsProps {
|
||||
centerIcon: LucideIcon;
|
||||
items: OrbitingItem[];
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoOrbitingIcons = ({
|
||||
centerIcon,
|
||||
items,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoOrbitingIconsProps) => {
|
||||
const theme = useTheme();
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
const CenterIcon = centerIcon;
|
||||
|
||||
const circleStyles = "secondary-button border border-background-accent! shadow rounded-full";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls("relative h-full flex flex-col overflow-hidden", className)}
|
||||
style={{
|
||||
perspective: "2000px",
|
||||
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-1 rounded-t-theme-capped gap-2 flex items-center justify-center w-full h-full inset-x-0 p-2 relative"
|
||||
style={{
|
||||
transform: "rotateY(20deg) rotateX(20deg) rotateZ(-20deg)"
|
||||
}}
|
||||
>
|
||||
{/* Background concentric circles */}
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[15rem] w-[15rem] z-[9] opacity-85", circleStyles)} />
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[20rem] w-[20rem] z-[8] opacity-65", circleStyles)} />
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[25rem] w-[25rem] z-[7] opacity-45", circleStyles)} />
|
||||
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[30rem] w-[30rem] z-[6] opacity-25", circleStyles)} />
|
||||
|
||||
{/* Center circle with icon */}
|
||||
<div className={cls("absolute! inset-0 shrink-0 h-40 w-[10rem] z-10 m-auto flex items-center justify-center", circleStyles)}>
|
||||
|
||||
<div className="absolute! primary-button h-[5rem] w-[5rem] rounded-full flex items-center justify-center" >
|
||||
<CenterIcon className="absolute h-1/2 w-1/2 text-primary-cta-text" strokeWidth={1.25} />
|
||||
</div>
|
||||
|
||||
{/* Orbiting items */}
|
||||
{items.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const ring = item.ring || 2;
|
||||
// Ring radii: 7.5rem=120px, 10rem=160px, 12.5rem=200px
|
||||
const radiusMap = { 1: 120, 2: 160, 3: 200 };
|
||||
const radius = radiusMap[ring];
|
||||
const duration = item.duration || 10;
|
||||
// Evenly distribute items around the circle
|
||||
const initialPosition = (360 / items.length) * index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("!absolute top-1/2 left-1/2 h-[2.5rem] w-[2.5rem] card shadow rounded-theme flex items-center justify-center")}
|
||||
style={{
|
||||
marginLeft: '-1.25rem',
|
||||
marginTop: '-1.25rem',
|
||||
animation: `orbit ${duration}s linear infinite`,
|
||||
"--initial-position": `${initialPosition}deg`,
|
||||
"--translate-position": `${radius}px`,
|
||||
"--orbit-duration": `${duration}s`,
|
||||
} as React.CSSProperties & {
|
||||
"--initial-position": string;
|
||||
"--translate-position": string;
|
||||
"--orbit-duration": string;
|
||||
}}
|
||||
>
|
||||
<Icon className={cls("h-4/10 w-4/10", shouldUseLightText ? "text-background" : "text-foreground")} strokeWidth={1.5} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoOrbitingIcons.displayName = "BentoOrbitingIcons";
|
||||
|
||||
export default memo(BentoOrbitingIcons);
|
||||
115
src/components/bento/BentoPhoneAnimation.tsx
Normal file
115
src/components/bento/BentoPhoneAnimation.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type PhoneApp = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export type PhoneApps8 = [PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp];
|
||||
|
||||
interface BentoPhoneAnimationProps {
|
||||
statusIcon: LucideIcon;
|
||||
alertIcon: LucideIcon;
|
||||
alertTitle: string;
|
||||
alertMessage: string;
|
||||
apps: PhoneApps8;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoPhoneAnimation = ({
|
||||
statusIcon: StatusIcon,
|
||||
alertIcon: AlertIcon,
|
||||
alertTitle,
|
||||
alertMessage,
|
||||
apps,
|
||||
className = "",
|
||||
}: BentoPhoneAnimationProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"group/phone relative h-full flex flex-auto items-center justify-center overflow-hidden cursor-pointer",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-x-0 top-0 h-full overflow-hidden isolate",
|
||||
"pt-8 transition-[padding] duration-500 ease-out group-hover/phone:pt-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative mx-auto card shadow h-100 w-[calc(100%-var(--vw-2)*2)] rounded-[3vw] p-2",
|
||||
)}
|
||||
>
|
||||
<div className="w-full min-w-0 relative h-full overflow-hidden secondary-button rounded-[2.6vw] p-8 pt-6" >
|
||||
<div
|
||||
className="relative z-10 mx-auto h-7 w-auto aspect-square card shadow flex items-center justify-center rounded-full"
|
||||
>
|
||||
<StatusIcon className="h-4/10 w-4/10 text-foreground transition-colors duration-300 group-hover/phone:text-primary-cta" />
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! left-8 right-8 z-2 gap-[0.5vw] p-3 card flex flex-row items-center rounded-theme-capped",
|
||||
"-translate-y-30 scale-90 blur-[2px] opacity-50",
|
||||
"transition-all duration-500 ease-out",
|
||||
"group-hover/phone:translate-y-0 group-hover/phone:scale-100 group-hover/phone:blur-none group-hover/phone:opacity-100",
|
||||
)}
|
||||
style={{ top: "calc(var(--vw-1_5) + var(--height-7) + var(--vw-1_5))" }}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"relative h-8 w-auto aspect-square primary-button flex shrink-0 items-center justify-center rounded-theme",
|
||||
)}
|
||||
>
|
||||
<AlertIcon className="h-4/10 w-4/10 text-primary-cta-text" />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0">
|
||||
<h3
|
||||
className={cls(
|
||||
"text-sm leading-tight text-foreground",
|
||||
)}
|
||||
>
|
||||
{alertTitle}
|
||||
</h3>
|
||||
<p
|
||||
className={cls(
|
||||
"text-xs text-foreground/75 leading-tight truncate",
|
||||
)}
|
||||
>
|
||||
{alertMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full min-w-0 grid grid-cols-4 gap-6 mt-6">
|
||||
{apps.map(({ name, icon: Icon }) => (
|
||||
<div key={name} className="w-full min-w-0 flex flex-col items-center gap-2">
|
||||
<div className="aspect-square w-full primary-button rounded-theme-capped flex items-center justify-center">
|
||||
<Icon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<p className="w-full text-xs text-foreground text-center truncate">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoPhoneAnimation.displayName = "BentoPhoneAnimation";
|
||||
|
||||
export default memo(BentoPhoneAnimation);
|
||||
83
src/components/bento/BentoRevealIcon.tsx
Normal file
83
src/components/bento/BentoRevealIcon.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface BentoRevealIconProps {
|
||||
icon: LucideIcon;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BentoRevealIcon = ({
|
||||
icon: Icon,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoRevealIconProps) => {
|
||||
void useInvertedBackground;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"group relative h-full w-full flex items-center justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent), linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
|
||||
WebkitMaskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent), linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
|
||||
maskComposite: "intersect",
|
||||
WebkitMaskComposite: "source-in",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-26 w-[6.5rem]">
|
||||
<div
|
||||
className="absolute right-full top-1/2 -mt-48 transition-transform duration-500 ease-out group-hover:-translate-x-12"
|
||||
style={{ transform: "translateX(calc(52px + 1px - 2px))" }}
|
||||
>
|
||||
<div className="relative h-96 aspect-[224/280] -scale-x-100">
|
||||
<svg viewBox="0 0 224 280" fill="none" className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<path fill="currentColor" className="text-background-accent/10" d="M8 .25a8 8 0 0 0-8 8v91.704c0 2.258.954 4.411 2.628 5.927l10.744 9.738A7.998 7.998 0 0 1 16 121.546v36.408a7.998 7.998 0 0 1-2.628 5.927l-10.744 9.738A7.998 7.998 0 0 0 0 179.546v92.204a8 8 0 0 0 8 8h308a8 8 0 0 0 8-8V8.25a8 8 0 0 0-8-8H8Z" />
|
||||
<path stroke="currentColor" className="text-background-accent" d="M.5 99.954V8.25A7.5 7.5 0 0 1 8 .75h308a7.5 7.5 0 0 1 7.5 7.5v263.5a7.5 7.5 0 0 1-7.5 7.5H8a7.5 7.5 0 0 1-7.5-7.5v-92.204a7.5 7.5 0 0 1 2.464-5.557l10.744-9.737a8.5 8.5 0 0 0 2.792-6.298v-36.408a8.5 8.5 0 0 0-2.792-6.298l-10.744-9.737A7.5 7.5 0 0 1 .5 99.954Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute left-full top-1/2 -mt-48 transition-transform duration-500 ease-out group-hover:translate-x-12"
|
||||
style={{ transform: "translateX(calc(-52px - 1px + 2px))" }}
|
||||
>
|
||||
<div className="relative h-96 aspect-[224/280]">
|
||||
<svg viewBox="0 0 224 280" fill="none" className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<path fill="currentColor" className="text-background-accent/10" d="M8 .25a8 8 0 0 0-8 8v91.704c0 2.258.954 4.411 2.628 5.927l10.744 9.738A7.998 7.998 0 0 1 16 121.546v36.408a7.998 7.998 0 0 1-2.628 5.927l-10.744 9.738A7.998 7.998 0 0 0 0 179.546v92.204a8 8 0 0 0 8 8h308a8 8 0 0 0 8-8V8.25a8 8 0 0 0-8-8H8Z" />
|
||||
<path stroke="currentColor" className="text-background-accent" d="M.5 99.954V8.25A7.5 7.5 0 0 1 8 .75h308a7.5 7.5 0 0 1 7.5 7.5v263.5a7.5 7.5 0 0 1-7.5 7.5H8a7.5 7.5 0 0 1-7.5-7.5v-92.204a7.5 7.5 0 0 1 2.464-5.557l10.744-9.737a8.5 8.5 0 0 0 2.792-6.298v-36.408a8.5 8.5 0 0 0-2.792-6.298l-10.744-9.737A7.5 7.5 0 0 1 .5 99.954Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-full p-2">
|
||||
<div className="relative w-full h-full primary-button rounded-theme flex items-center justify-center">
|
||||
<Icon className="relative z-10 h-4/10 w-auto text-primary-cta-text" strokeWidth={1.25} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-px z-10 rounded-full mix-blend-overlay"
|
||||
style={{ clipPath: "circle(50%)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 z-10 transition-transform duration-500 ease-out group-hover:translate-x-0 group-hover:translate-y-0"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(to bottom right, transparent 30%, black, transparent 70%)",
|
||||
transform: "translate(-65px, -65px)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoRevealIcon.displayName = "BentoRevealIcon";
|
||||
|
||||
export default memo(BentoRevealIcon);
|
||||
115
src/components/bento/BentoTimeline.tsx
Normal file
115
src/components/bento/BentoTimeline.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { Check, Loader } from "lucide-react";
|
||||
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
export type TimelineItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
interface BentoTimelineProps {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
items: [TimelineItem, TimelineItem, TimelineItem];
|
||||
completedLabel: string;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const itemDelays = [
|
||||
{ check: 'delay-[150ms]', label: 'delay-[200ms]', detail: 'delay-[250ms]' },
|
||||
{ check: 'delay-[350ms]', label: 'delay-[400ms]', detail: 'delay-[450ms]' },
|
||||
{ check: 'delay-[550ms]', label: 'delay-[600ms]', detail: 'delay-[650ms]' },
|
||||
] as const;
|
||||
|
||||
const BentoTimeline = ({
|
||||
heading,
|
||||
subheading,
|
||||
items,
|
||||
completedLabel,
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
}: BentoTimelineProps) => {
|
||||
void useInvertedBackground;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
"group relative h-full w-full flex items-center justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-100" />
|
||||
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-80" />
|
||||
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-60" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-full min-w-0 flex flex-col gap-3 p-4 mask-fade-y-small">
|
||||
<div className="card shadow rounded-theme-capped p-3 flex items-center gap-2">
|
||||
<Loader className="h-[var(--text-sm)] w-auto text-primary transition-transform duration-1000 ease-out group-hover:rotate-[360deg]" strokeWidth={1.5} />
|
||||
<p className="text-xs text-foreground truncate">{heading}</p>
|
||||
<p className="text-xs text-foreground/75 ml-auto text-nowrap">{subheading}</p>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="card shadow rounded-theme-capped px-3 py-2 flex items-center gap-2"
|
||||
>
|
||||
<div className="relative h-6 w-auto aspect-square card shadow rounded-theme flex items-center justify-center">
|
||||
<div className="absolute! h-3/10 w-3/10 primary-button rounded-theme transition-opacity duration-300 group-hover:opacity-0" />
|
||||
<div
|
||||
className={cls(
|
||||
"absolute! inset-0 rounded-theme primary-button flex items-center justify-center",
|
||||
"opacity-0 scale-75 transition-all duration-300",
|
||||
`group-hover:opacity-100 group-hover:scale-100 ${itemDelays[index].check}`
|
||||
)}
|
||||
>
|
||||
<Check className="h-1/2 w-1/2 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-0 max-w-full flex-1 flex items-center gap-10 justify-between">
|
||||
<p
|
||||
className={cls(
|
||||
"text-xs text-foreground truncate opacity-0 transition-all duration-300",
|
||||
`group-hover:opacity-100 ${itemDelays[index].label}`
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p
|
||||
className={cls(
|
||||
"text-xs text-foreground/75 text-nowrap opacity-0 translate-y-1 transition-all duration-300",
|
||||
`group-hover:opacity-100 group-hover:translate-y-0 ${itemDelays[index].detail}`
|
||||
)}
|
||||
>
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="primary-button rounded-theme-capped p-3 flex items-center justify-center">
|
||||
<div className="absolute flex gap-2 transition-opacity duration-500 delay-[900ms] group-hover:opacity-0">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-2 w-auto aspect-square rounded-theme bg-primary-cta-text" />
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="text-xs text-primary-cta-text truncate opacity-0 transition-opacity duration-500 delay-[900ms] group-hover:opacity-100"
|
||||
>
|
||||
{completedLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BentoTimeline.displayName = "BentoTimeline";
|
||||
|
||||
export default memo(BentoTimeline);
|
||||
41
src/components/button/Button.tsx
Normal file
41
src/components/button/Button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import ButtonHoverMagnetic from "./ButtonHoverMagnetic/ButtonHoverMagnetic";
|
||||
import ButtonIconArrow from "./ButtonIconArrow";
|
||||
import ButtonShiftHover from "./ButtonShiftHover/ButtonShiftHover";
|
||||
import ButtonTextStagger from "./ButtonTextStagger/ButtonTextStagger";
|
||||
import ButtonTextUnderline from "./ButtonTextUnderline";
|
||||
import ButtonHoverBubble from "./ButtonHoverBubble";
|
||||
import ButtonExpandHover from "./ButtonExpandHover";
|
||||
import ButtonElasticEffect from "./ButtonElasticEffect/ButtonElasticEffect";
|
||||
import ButtonBounceEffect from "./ButtonBounceEffect/ButtonBounceEffect";
|
||||
import ButtonDirectionalHover from "./ButtonDirectionalHover/ButtonDirectionalHover";
|
||||
import ButtonTextShift from "./ButtonTextShift/ButtonTextShift";
|
||||
import type { ButtonVariantProps } from "./types";
|
||||
|
||||
export type { ButtonVariant, ButtonVariantProps, ButtonPropsForVariant } from "./types";
|
||||
|
||||
const buttonComponents = {
|
||||
"hover-magnetic": ButtonHoverMagnetic,
|
||||
"hover-bubble": ButtonHoverBubble,
|
||||
"expand-hover": ButtonExpandHover,
|
||||
"elastic-effect": ButtonElasticEffect,
|
||||
"bounce-effect": ButtonBounceEffect,
|
||||
"icon-arrow": ButtonIconArrow,
|
||||
"shift-hover": ButtonShiftHover,
|
||||
"text-stagger": ButtonTextStagger,
|
||||
"text-shift": ButtonTextShift,
|
||||
"text-underline": ButtonTextUnderline,
|
||||
"directional-hover": ButtonDirectionalHover,
|
||||
} as const;
|
||||
|
||||
const Button = (props: ButtonVariantProps) => {
|
||||
const { variant = "hover-magnetic", ...restProps } = props;
|
||||
const ButtonComponent = buttonComponents[variant];
|
||||
return <ButtonComponent {...restProps} />;
|
||||
};
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default memo(Button);
|
||||
30
src/components/button/ButtonBounceEffect/BounceButton.css
Normal file
30
src/components/button/ButtonBounceEffect/BounceButton.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.bounce-button {
|
||||
--ease-elastic: linear(0, 0.55 7.5%, 0.85 12%, 0.95 14%, 1.03 16.5%, 1.09 20%, 1.13 22%, 1.14 23%, 1.15 24.5%, 1.15 26%, 1.13 28%, 1.11 31%, 1.05 39%, 1.02 43%, 0.99 47%, 0.98 52%, 0.97 59%, 1.002 81%, 1);
|
||||
transition: transform 0.65s var(--ease-elastic);
|
||||
}
|
||||
|
||||
.bounce-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm) * 1.5) currentColor;
|
||||
transform: translateY(0) rotate(0.001deg);
|
||||
transition: transform 0.65s var(--ease-elastic);
|
||||
}
|
||||
|
||||
.bounce-button:hover {
|
||||
transform: scale(0.92) rotate(-3deg);
|
||||
}
|
||||
|
||||
.bounce-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(3deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bounce-button:hover {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
|
||||
.bounce-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0) rotate(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./BounceButton.css";
|
||||
|
||||
interface ButtonBounceEffectProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonBounceEffect = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonBounceEffectProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
data-href={href}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"bounce-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"bounce-button-bg absolute! inset-0 rounded-theme primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className={cls(
|
||||
"bounce-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonBounceEffect.displayName = "ButtonBounceEffect";
|
||||
|
||||
export default memo(ButtonBounceEffect);
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useDirectionalHover } from "./useDirectionalHover";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./DirectionalButton.css";
|
||||
|
||||
export interface ButtonDirectionalHoverProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
circleClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonDirectionalHover = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
circleClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonDirectionalHoverProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useDirectionalHover(buttonRef);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
data-href={href}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"directional-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"directional-button-bg absolute! inset-0 rounded-theme primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<div className="directional-button-circle-wrap">
|
||||
<div
|
||||
className={cls(
|
||||
"directional-button-circle bg-accent",
|
||||
circleClassName
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<span
|
||||
className={cls(
|
||||
"directional-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonDirectionalHover.displayName = "ButtonDirectionalHover";
|
||||
|
||||
export default memo(ButtonDirectionalHover);
|
||||
@@ -0,0 +1,37 @@
|
||||
.directional-button-circle-wrap {
|
||||
border-radius: inherit;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.directional-button-circle {
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transition: transform 0.7s cubic-bezier(0.625, 0.05, 0, 1);
|
||||
transform: translate(-50%, -50%) scale(0) rotate(0.001deg);
|
||||
}
|
||||
|
||||
.directional-button-circle::before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
|
||||
.directional-button:hover .directional-button-circle {
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0.001deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.directional-button:hover .directional-button-circle {
|
||||
transform: translate(-50%, -50%) scale(0) rotate(0.001deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useCallback, RefObject } from "react";
|
||||
|
||||
export const useDirectionalHover = (
|
||||
buttonRef: RefObject<HTMLButtonElement | null>,
|
||||
circleSelector: string = ".directional-button-circle"
|
||||
) => {
|
||||
const handleHover = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const buttonWidth = buttonRect.width;
|
||||
const buttonHeight = buttonRect.height;
|
||||
const buttonCenterX = buttonRect.left + buttonWidth / 2;
|
||||
|
||||
const mouseX = event.clientX;
|
||||
const mouseY = event.clientY;
|
||||
|
||||
const offsetXFromLeft = ((mouseX - buttonRect.left) / buttonWidth) * 100;
|
||||
const offsetYFromTop = ((mouseY - buttonRect.top) / buttonHeight) * 100;
|
||||
|
||||
let offsetXFromCenter = ((mouseX - buttonCenterX) / (buttonWidth / 2)) * 50;
|
||||
offsetXFromCenter = Math.abs(offsetXFromCenter);
|
||||
|
||||
const circle = button.querySelector(circleSelector) as HTMLElement;
|
||||
if (circle) {
|
||||
circle.style.left = `${offsetXFromLeft.toFixed(1)}%`;
|
||||
circle.style.top = `${offsetYFromTop.toFixed(1)}%`;
|
||||
circle.style.width = `${115 + offsetXFromCenter * 2}%`;
|
||||
}
|
||||
},
|
||||
[buttonRef, circleSelector]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener("mouseenter", handleHover);
|
||||
button.addEventListener("mouseleave", handleHover);
|
||||
|
||||
return () => {
|
||||
button.removeEventListener("mouseenter", handleHover);
|
||||
button.removeEventListener("mouseleave", handleHover);
|
||||
};
|
||||
}, [buttonRef, handleHover]);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import useElasticEffect from "./useElasticEffect";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonElasticEffectProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonElasticEffect = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonElasticEffectProps) => {
|
||||
const elasticRef = useElasticEffect<HTMLButtonElement>();
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={elasticRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative cursor-pointer h-9 min-w-0 w-fit max-w-full px-6 primary-button rounded-theme text-primary-cta-text",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonElasticEffect.displayName = "ButtonElasticEffect";
|
||||
|
||||
export default memo(ButtonElasticEffect);
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import gsap from "gsap";
|
||||
|
||||
const useElasticEffect = <T extends HTMLElement>() => {
|
||||
const elementRef = useRef<T>(null);
|
||||
const hoverLockedRef = useRef(false);
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
const el = elementRef.current;
|
||||
if (!el || hoverLockedRef.current) return;
|
||||
|
||||
hoverLockedRef.current = true;
|
||||
setTimeout(() => {
|
||||
hoverLockedRef.current = false;
|
||||
}, 500);
|
||||
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
const fs = parseFloat(getComputedStyle(el).fontSize);
|
||||
const stretch = 0.75 * fs;
|
||||
const sx = (w + stretch) / w;
|
||||
const sy = (h - stretch * 0.33) / h;
|
||||
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.kill();
|
||||
}
|
||||
|
||||
timelineRef.current = gsap
|
||||
.timeline()
|
||||
.to(el, { scaleX: sx, scaleY: sy, duration: 0.1, ease: "power1.out" })
|
||||
.to(el, { scaleX: 1, scaleY: 1, duration: 1, ease: "elastic.out(1, 0.3)" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip on touch devices
|
||||
if (window.matchMedia("(hover: none) and (pointer: coarse)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener("mouseenter", handleMouseEnter);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("mouseenter", handleMouseEnter);
|
||||
if (timelineRef.current) {
|
||||
timelineRef.current.kill();
|
||||
}
|
||||
};
|
||||
}, [handleMouseEnter]);
|
||||
|
||||
return elementRef;
|
||||
};
|
||||
|
||||
export default useElasticEffect;
|
||||
92
src/components/button/ButtonExpandHover.tsx
Normal file
92
src/components/button/ButtonExpandHover.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonExpandHoverProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
iconBgClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonExpandHover = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
iconClassName = "",
|
||||
iconBgClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonExpandHoverProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"group relative cursor-pointer h-fit min-w-0 w-fit max-w-full rounded-theme text-sm text-background pointer-events-auto outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative h-9 w-full px-5"
|
||||
style={{ paddingRight: "calc(2.25rem + 0.75rem)" }}
|
||||
>
|
||||
<div className="h-9 flex items-center" >
|
||||
<span
|
||||
className={cls(
|
||||
"relative z-10 block overflow-hidden truncate whitespace-nowrap md:transition-colors md:duration-[900ms] md:[transition-timing-function:cubic-bezier(.77,0,.18,1)]",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute overflow-hidden top-[2px] bottom-[2px] left-[2px] right-[2px] rounded-theme flex justify-end">
|
||||
<div
|
||||
className={cls(
|
||||
"relative z-10 h-full w-auto aspect-square flex items-center justify-center",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight
|
||||
className="h-1/2 w-auto aspect-square"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute z-0 h-full w-full rounded-theme",
|
||||
"md:transition-transform md:duration-[900ms] md:[transition-timing-function:cubic-bezier(.77,0,.18,1)]",
|
||||
"-translate-x-[calc(-100%+2.25rem-4px)] md:group-hover:translate-x-0",
|
||||
iconBgClassName
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonExpandHover.displayName = "ButtonExpandHover";
|
||||
|
||||
export default memo(ButtonExpandHover);
|
||||
83
src/components/button/ButtonHoverBubble.tsx
Normal file
83
src/components/button/ButtonHoverBubble.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ArrowDownRight } from "lucide-react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonHoverBubbleProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonHoverBubble = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
iconClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonHoverBubbleProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
data-href={href}
|
||||
className={cls(
|
||||
"relative group flex justify-center items-center min-w-0 w-fit max-w-full rounded-theme cursor-pointer pointer-events-auto outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"flex justify-center items-center h-9 aspect-square rounded-theme relative",
|
||||
"scale-0 md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:origin-left md:group-hover:scale-100",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<ArrowDownRight strokeWidth={1.5} className="h-[35%] w-auto aspect-square object-contain md:transition-transform md:duration-700 md:group-hover:rotate-[-45deg]" />
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"flex justify-center items-center h-9 px-4 min-w-0 w-fit max-w-full rounded-theme relative",
|
||||
"-translate-x-[var(--height-9)] md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:group-hover:translate-x-0",
|
||||
bgClassName
|
||||
)}
|
||||
>
|
||||
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cls(
|
||||
"flex justify-center items-center h-9 aspect-square rounded-theme absolute right-0 z-20",
|
||||
"scale-100 md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:origin-right md:group-hover:scale-0",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
<ArrowDownRight strokeWidth={1.5} className="h-[35%] w-auto aspect-square object-contain md:transition-transform md:duration-700 md:group-hover:rotate-[-45deg]" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonHoverBubble.displayName = "ButtonHoverBubble";
|
||||
|
||||
export default memo(ButtonHoverBubble);
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import useMagneticEffect from "./useMagneticEffect";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonHoverMagneticProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
strengthFactor?: number;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonHoverMagnetic = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
strengthFactor = 20,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonHoverMagneticProps) => {
|
||||
const magneticRef = useMagneticEffect(strengthFactor);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={magneticRef as React.RefObject<HTMLButtonElement>}
|
||||
data-href={href}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative cursor-pointer h-9 min-w-0 w-fit max-w-full px-6 rounded-theme",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonHoverMagnetic.displayName = "ButtonHoverMagnetic";
|
||||
|
||||
export default memo(ButtonHoverMagnetic);
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const useMagneticEffect = (strengthFactor = 10) => {
|
||||
const elementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
import("gsap").then((gsap) => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element || window.innerWidth < 768) return;
|
||||
|
||||
const resetEl = (el: HTMLElement, immediate: boolean) => {
|
||||
if (!el) return;
|
||||
gsap.default.killTweensOf(el);
|
||||
(immediate ? gsap.default.set : gsap.default.to)(el, {
|
||||
x: "0vw",
|
||||
y: "0vw",
|
||||
rotate: "0deg",
|
||||
clearProps: "all",
|
||||
...(!immediate && { ease: "elastic.out(1, 0.3)", duration: 1.6 })
|
||||
});
|
||||
};
|
||||
|
||||
const resetOnEnter = () => {
|
||||
resetEl(element, true);
|
||||
};
|
||||
|
||||
const moveMagnet = (e: MouseEvent) => {
|
||||
const b = element.getBoundingClientRect();
|
||||
const strength = strengthFactor;
|
||||
|
||||
const offsetX = ((e.clientX - b.left) / element.offsetWidth - 0.5) * (strength / 16);
|
||||
const offsetY = ((e.clientY - b.top) / element.offsetHeight - 0.5) * (strength / 16);
|
||||
|
||||
gsap.default.to(element, {
|
||||
x: offsetX + "vw",
|
||||
y: offsetY + "vw",
|
||||
rotate: "0.001deg",
|
||||
ease: "power4.out",
|
||||
duration: 1.6
|
||||
});
|
||||
};
|
||||
|
||||
const resetMagnet = () => {
|
||||
gsap.default.to(element, {
|
||||
x: "0vw",
|
||||
y: "0vw",
|
||||
ease: "elastic.out(1, 0.3)",
|
||||
duration: 1.6,
|
||||
clearProps: "all"
|
||||
});
|
||||
};
|
||||
|
||||
element.addEventListener("mouseenter", resetOnEnter);
|
||||
element.addEventListener("mousemove", moveMagnet);
|
||||
element.addEventListener("mouseleave", resetMagnet);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("mouseenter", resetOnEnter);
|
||||
element.removeEventListener("mousemove", moveMagnet);
|
||||
element.removeEventListener("mouseleave", resetMagnet);
|
||||
};
|
||||
});
|
||||
}, [strengthFactor]);
|
||||
|
||||
return elementRef;
|
||||
};
|
||||
|
||||
export default useMagneticEffect;
|
||||
66
src/components/button/ButtonIconArrow.tsx
Normal file
66
src/components/button/ButtonIconArrow.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonIconArrowProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonIconArrow = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
iconClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonIconArrowProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative group cursor-pointer h-9 min-w-0 w-fit max-w-full primary-button rounded-theme px-6 text-sm text-primary-cta-text flex items-center gap-3",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cls(
|
||||
"block overflow-hidden truncate whitespace-nowrap md:transition-transform md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:[transform:translateX(calc(var(--height-9)/4))]",
|
||||
textClassName
|
||||
)}>
|
||||
{text}
|
||||
</span>
|
||||
<div className={cls(
|
||||
"h-5 w-[var(--height-5)] aspect-square rounded-theme flex items-center justify-center md:transition-transform md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:scale-[0.2] md:group-hover:rotate-90",
|
||||
iconClassName || "secondary-button text-secondary-cta-text"
|
||||
)}>
|
||||
<ArrowRight className="h-1/2 w-1/2 md:transition-opacity md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:opacity-0" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonIconArrow.displayName = "ButtonIconArrow";
|
||||
|
||||
export default memo(ButtonIconArrow);
|
||||
73
src/components/button/ButtonShiftHover/ButtonShiftHover.tsx
Normal file
73
src/components/button/ButtonShiftHover/ButtonShiftHover.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./ShiftButton.css";
|
||||
|
||||
interface ButtonShiftHoverProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonShiftHover = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonShiftHoverProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"shift-button group relative cursor-pointer flex gap-2 items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-5 pr-4 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text text-sm",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
textClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"shift-button-bg absolute! inset-0 rounded-theme transition-transform duration-[600ms] primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className="shift-button-text relative inline-block overflow-hidden truncate whitespace-nowrap"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<div className="relative h-[1em] w-auto aspect-square rounded-theme border border-current scale-65 transition-all duration-300 md:group-hover:bg-current md:group-hover:scale-40" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonShiftHover.displayName = "ButtonShiftHover";
|
||||
|
||||
export default memo(ButtonShiftHover);
|
||||
29
src/components/button/ButtonShiftHover/ShiftButton.css
Normal file
29
src/components/button/ButtonShiftHover/ShiftButton.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.shift-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
transform: translateY(0em) rotate(0.001deg);
|
||||
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.shift-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
|
||||
}
|
||||
|
||||
.shift-button:hover .shift-button-bg {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shift-button [data-button-animate-chars] span {
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
}
|
||||
|
||||
.shift-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0vw) rotate(0);
|
||||
}
|
||||
|
||||
.shift-button:hover .shift-button-bg {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
75
src/components/button/ButtonSlideBackground.tsx
Normal file
75
src/components/button/ButtonSlideBackground.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonSlideBackgroundProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonSlideBackground = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonSlideBackgroundProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
const cubicBezier = "cubic-bezier(0.4, 0, 0, 1)";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"group relative flex items-center justify-center h-9 min-w-0 w-fit max-w-full px-6 rounded-theme overflow-hidden cursor-pointer",
|
||||
"primary-button",
|
||||
"after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-full",
|
||||
"after:translate-y-[101%] after:rounded-t-[50%] hover:after:translate-y-0 hover:after:rounded-none",
|
||||
"after:transition-all after:duration-500",
|
||||
"after:bg-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
transform: "scaleX(1)",
|
||||
transition: `transform 0.5s ${cubicBezier}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cls(
|
||||
"inline-block text-sm overflow-hidden relative",
|
||||
"text-primary-cta-text",
|
||||
"after:content-[attr(data-text)] after:w-full after:h-full after:inline-block after:absolute",
|
||||
"after:left-1/2 after:bottom-0 after:z-[1] after:-translate-x-1/2 after:translate-y-full group-hover:after:translate-y-0",
|
||||
"after:transition-transform after:duration-500 after:ease-[cubic-bezier(0.2,0,0,1)]",
|
||||
"after:text-foreground",
|
||||
textClassName
|
||||
)}
|
||||
data-text={text}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonSlideBackground.displayName = "ButtonSlideBackground";
|
||||
|
||||
export default memo(ButtonSlideBackground);
|
||||
74
src/components/button/ButtonTextShift/ButtonTextShift.tsx
Normal file
74
src/components/button/ButtonTextShift/ButtonTextShift.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./TextShiftButton.css";
|
||||
|
||||
export interface ButtonTextShiftProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonTextShift = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonTextShiftProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text, "[data-button-animate-chars]", 0.0);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
data-href={href}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"stagger-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"stagger-button-bg absolute! inset-0 rounded-theme primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className={cls(
|
||||
"stagger-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonTextShift.displayName = "ButtonTextShift";
|
||||
|
||||
export default memo(ButtonTextShift);
|
||||
21
src/components/button/ButtonTextShift/TextShiftButton.css
Normal file
21
src/components/button/ButtonTextShift/TextShiftButton.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
transform: translateY(0em) rotate(0.001deg);
|
||||
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0vw) rotate(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, memo } from "react";
|
||||
import { useCharAnimation } from "../useCharAnimation";
|
||||
import { useButtonClick } from "../useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
import "./StaggerButton.css";
|
||||
|
||||
export interface ButtonTextStaggerProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonTextStagger = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
bgClassName = "",
|
||||
textClassName = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonTextStaggerProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
useCharAnimation(buttonRef, text, "[data-button-animate-chars]", 0.01);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
data-href={href}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"stagger-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"stagger-button-bg absolute! inset-0 rounded-theme transition-transform duration-[600ms] primary-button",
|
||||
bgClassName
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
data-button-animate-chars=""
|
||||
className={cls(
|
||||
"stagger-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
|
||||
textClassName
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonTextStagger.displayName = "ButtonTextStagger";
|
||||
|
||||
export default memo(ButtonTextStagger);
|
||||
29
src/components/button/ButtonTextStagger/StaggerButton.css
Normal file
29
src/components/button/ButtonTextStagger/StaggerButton.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
transform: translateY(0em) rotate(0.001deg);
|
||||
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
|
||||
}
|
||||
|
||||
.stagger-button:hover .stagger-button-bg {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stagger-button [data-button-animate-chars] span {
|
||||
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
|
||||
}
|
||||
|
||||
.stagger-button:hover [data-button-animate-chars] span {
|
||||
transform: translateY(0vw) rotate(0);
|
||||
}
|
||||
|
||||
.stagger-button:hover .stagger-button-bg {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
53
src/components/button/ButtonTextUnderline.tsx
Normal file
53
src/components/button/ButtonTextUnderline.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useButtonClick } from "./useButtonClick";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface ButtonTextUnderlineProps {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
scrollToSection?: boolean;
|
||||
}
|
||||
|
||||
const ButtonTextUnderline = ({
|
||||
text,
|
||||
onClick,
|
||||
href,
|
||||
className = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
scrollToSection,
|
||||
}: ButtonTextUnderlineProps) => {
|
||||
const handleClick = useButtonClick(href, onClick, scrollToSection);
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-href={href}
|
||||
aria-label={ariaLabel || text}
|
||||
className={cls(
|
||||
"relative text-sm inline-block bg-transparent border-none p-0 cursor-pointer",
|
||||
"after:content-[''] after:absolute after:bottom-0 after:left-0 after:w-full after:h-[1px]",
|
||||
"after:bg-current after:scale-x-0 after:origin-right after:transition-transform after:duration-300",
|
||||
"hover:after:scale-x-100 hover:after:origin-left",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:after:scale-x-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonTextUnderline.displayName = "ButtonTextUnderline";
|
||||
|
||||
export default memo(ButtonTextUnderline);
|
||||
125
src/components/button/SelectorButton.tsx
Normal file
125
src/components/button/SelectorButton.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, memo, ReactNode } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
export interface SelectorOption {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export interface SelectorButtonProps {
|
||||
options: SelectorOption[];
|
||||
activeValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const SelectorButton = memo<SelectorButtonProps>(({
|
||||
options,
|
||||
activeValue,
|
||||
onValueChange,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
wrapperClassName = "",
|
||||
labelClassName = "",
|
||||
}) => {
|
||||
const hoverRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const hoverElement = hoverRef.current;
|
||||
|
||||
if (!container || !hoverElement) return;
|
||||
|
||||
const moveHoverBlock = (target: HTMLElement) => {
|
||||
if (!target) return;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
hoverElement.style.width = `${targetRect.width}px`;
|
||||
hoverElement.style.transform = `translateX(${targetRect.left - containerRect.left}px)`;
|
||||
};
|
||||
|
||||
const updatePosition = () => {
|
||||
const activeButton = container.querySelector(
|
||||
`[data-value="${activeValue}"]`
|
||||
) as HTMLElement;
|
||||
if (activeButton) moveHoverBlock(activeButton);
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updatePosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [activeValue]);
|
||||
|
||||
return (
|
||||
<div className={cls("relative w-fit p-1 card rounded-theme-capped", wrapperClassName)}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cls("relative overflow-hidden cursor-pointer flex", className)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
data-value={option.value}
|
||||
disabled={option.disabled}
|
||||
onClick={() => !option.disabled && onValueChange(option.value)}
|
||||
className={cls(
|
||||
"relative px-4 py-2 text-sm md:text-base rounded-theme transition-all duration-300 ease-in-out z-1 text-nowrap",
|
||||
option.disabled ? "opacity-50" : "cursor-pointer",
|
||||
activeValue === option.value ? "" : "bg-transparent",
|
||||
buttonClassName
|
||||
)}
|
||||
>
|
||||
{typeof option.label === "string" ? (
|
||||
<span
|
||||
className={cls(
|
||||
"transition-colors duration-300 ease-in-out",
|
||||
activeValue === option.value ? "text-primary-cta-text" : "text-foreground",
|
||||
option.disabled ? "" : "cursor-pointer",
|
||||
option.labelClassName || labelClassName
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={cls(
|
||||
"flex items-center justify-center transition-opacity duration-300",
|
||||
activeValue === option.value ? "opacity-100" : "opacity-50",
|
||||
option.disabled ? "" : "cursor-pointer",
|
||||
option.labelClassName || labelClassName
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={hoverRef}
|
||||
className="absolute top-0 left-0 h-full rounded-theme overflow-hidden pointer-events-none z-0 transition-all duration-400 ease-out"
|
||||
>
|
||||
<div className="relative primary-button w-full h-full rounded-theme" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SelectorButton.displayName = "SelectorButton";
|
||||
|
||||
export default SelectorButton;
|
||||
91
src/components/button/types.ts
Normal file
91
src/components/button/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type ButtonVariant =
|
||||
| "hover-magnetic"
|
||||
| "hover-bubble"
|
||||
| "expand-hover"
|
||||
| "elastic-effect"
|
||||
| "bounce-effect"
|
||||
| "icon-arrow"
|
||||
| "shift-hover"
|
||||
| "text-stagger"
|
||||
| "text-shift"
|
||||
| "text-underline"
|
||||
| "directional-hover";
|
||||
|
||||
export type CTAButtonVariant = Exclude<ButtonVariant, "text-underline">;
|
||||
|
||||
export type ButtonWithBgClassName = "text-stagger" | "text-shift" | "shift-hover" | "bounce-effect" | "directional-hover";
|
||||
|
||||
export const hasBgClassName = (variant?: string): variant is ButtonWithBgClassName => {
|
||||
return variant === "text-stagger" || variant === "text-shift" || variant === "shift-hover" || variant === "bounce-effect" || variant === "directional-hover";
|
||||
};
|
||||
|
||||
export type BaseButtonProps = {
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
className?: string;
|
||||
scrollToSection?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
};
|
||||
|
||||
export type ButtonVariantProps =
|
||||
| ({
|
||||
variant?: "hover-magnetic";
|
||||
textClassName?: string;
|
||||
strengthFactor?: number;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "hover-bubble";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "expand-hover";
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
iconBgClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "elastic-effect";
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "bounce-effect";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "icon-arrow";
|
||||
textClassName?: string;
|
||||
iconClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "shift-hover";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "text-stagger";
|
||||
bgClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "text-shift";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "text-underline";
|
||||
disabled?: boolean;
|
||||
} & BaseButtonProps)
|
||||
| ({
|
||||
variant: "directional-hover";
|
||||
bgClassName?: string;
|
||||
textClassName?: string;
|
||||
circleClassName?: string;
|
||||
} & BaseButtonProps);
|
||||
|
||||
export type ButtonPropsForVariant<V extends ButtonVariant> = Extract<
|
||||
ButtonVariantProps,
|
||||
{ variant?: V }
|
||||
>;
|
||||
74
src/components/button/useButtonClick.ts
Normal file
74
src/components/button/useButtonClick.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useLenis } from "lenis/react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useButtonClick = (
|
||||
href?: string,
|
||||
onClick?: () => void,
|
||||
scrollToSection?: boolean
|
||||
) => {
|
||||
const lenis = useLenis();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const scrollToElement = (sectionId: string, delay: number = 100) => {
|
||||
setTimeout(() => {
|
||||
if (lenis) {
|
||||
lenis.scrollTo(`#${sectionId}`, { offset: 0 });
|
||||
} else {
|
||||
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 !== pathname) {
|
||||
router.push(path);
|
||||
if (hash) {
|
||||
setTimeout(() => {
|
||||
window.location.hash = hash;
|
||||
scrollToElement(hash, 100);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
if (hash) {
|
||||
window.location.hash = hash;
|
||||
scrollToElement(hash, 50);
|
||||
} else if (scrollToSection) {
|
||||
const sectionId = path.replace(/^\//, "").replace(/\//g, "-");
|
||||
scrollToElement(sectionId, 50);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollToElement(href, 50);
|
||||
}
|
||||
}
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.location.hash) {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
scrollToElement(hash, 300);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return handleClick;
|
||||
};
|
||||
31
src/components/button/useCharAnimation.ts
Normal file
31
src/components/button/useCharAnimation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect, RefObject } from "react";
|
||||
|
||||
export const useCharAnimation = (
|
||||
buttonRef: RefObject<HTMLButtonElement | null>,
|
||||
text: string | undefined,
|
||||
selector: string = "[data-button-animate-chars]",
|
||||
staggerDelay: number = 0
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const buttonElement = buttonRef.current?.querySelector(selector);
|
||||
if (!buttonElement) return;
|
||||
|
||||
const textContent = text || buttonElement.textContent || "";
|
||||
buttonElement.innerHTML = "";
|
||||
|
||||
[...textContent].forEach((char, index) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = char;
|
||||
|
||||
if (staggerDelay > 0) {
|
||||
span.style.transitionDelay = `${index * staggerDelay}s`;
|
||||
}
|
||||
|
||||
if (char === " ") {
|
||||
span.style.whiteSpace = "pre";
|
||||
}
|
||||
|
||||
buttonElement.appendChild(span);
|
||||
});
|
||||
}, [buttonRef, text, selector, staggerDelay]);
|
||||
};
|
||||
123
src/components/cardStack/CardList.tsx
Normal file
123
src/components/cardStack/CardList.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
|
||||
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, ButtonAnimationType, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
interface CardListProps {
|
||||
children: React.ReactNode;
|
||||
animationType: CardAnimationType;
|
||||
useUncappedRounding?: boolean;
|
||||
title?: string;
|
||||
titleSegments?: TitleSegment[];
|
||||
description?: string;
|
||||
tag?: string;
|
||||
tagIcon?: LucideIcon;
|
||||
tagAnimation?: ButtonAnimationType;
|
||||
buttons?: ButtonConfig[];
|
||||
buttonAnimation?: ButtonAnimationType;
|
||||
textboxLayout: TextboxLayout;
|
||||
useInvertedBackground?: InvertedBackground;
|
||||
disableCardWrapper?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
cardClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
titleClassName?: string;
|
||||
titleImageWrapperClassName?: string;
|
||||
titleImageClassName?: string;
|
||||
descriptionClassName?: string;
|
||||
tagClassName?: string;
|
||||
buttonContainerClassName?: string;
|
||||
buttonClassName?: string;
|
||||
buttonTextClassName?: string;
|
||||
}
|
||||
|
||||
const CardList = ({
|
||||
children,
|
||||
animationType,
|
||||
useUncappedRounding = false,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
disableCardWrapper = false,
|
||||
ariaLabel = "Card list",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}: CardListProps) => {
|
||||
const childrenArray = Children.toArray(children);
|
||||
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length, useIndividualTriggers: true });
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={ariaLabel}
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{childrenArray.map((child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cls(!disableCardWrapper && "card", !disableCardWrapper && (useUncappedRounding ? "rounded-theme" : "rounded-theme-capped"), cardClassName)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
CardList.displayName = "CardList";
|
||||
|
||||
export default memo(CardList);
|
||||
229
src/components/cardStack/CardStack.tsx
Normal file
229
src/components/cardStack/CardStack.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import { CardStackProps } from "./types";
|
||||
import GridLayout from "./layouts/grid/GridLayout";
|
||||
import AutoCarousel from "./layouts/carousels/AutoCarousel";
|
||||
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
|
||||
import TimelineBase from "./layouts/timelines/TimelineBase";
|
||||
import { gridConfigs } from "./layouts/grid/gridConfigs";
|
||||
|
||||
const CardStack = ({
|
||||
children,
|
||||
mode = "buttons",
|
||||
gridVariant = "uniform-all-items-equal",
|
||||
uniformGridCustomHeightClasses,
|
||||
gridRowsClassName,
|
||||
itemHeightClassesOverride,
|
||||
animationType,
|
||||
supports3DAnimation = false,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
carouselThreshold = 5,
|
||||
bottomContent,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
carouselItemClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel = "Card stack",
|
||||
}: CardStackProps) => {
|
||||
const childrenArray = Children.toArray(children);
|
||||
const itemCount = childrenArray.length;
|
||||
|
||||
// Check if the current grid config has gridRows defined
|
||||
const gridConfig = gridConfigs[gridVariant]?.[itemCount];
|
||||
const hasFixedGridRows = gridConfig && 'gridRows' in gridConfig && gridConfig.gridRows;
|
||||
|
||||
// If grid has fixed row heights and we have uniformGridCustomHeightClasses,
|
||||
// we need to use min-h-0 on md+ to prevent conflicts
|
||||
let adjustedHeightClasses = uniformGridCustomHeightClasses;
|
||||
if (hasFixedGridRows && uniformGridCustomHeightClasses) {
|
||||
// Extract the mobile min-height and add md:min-h-0
|
||||
const mobileMinHeight = uniformGridCustomHeightClasses.split(' ')[0];
|
||||
adjustedHeightClasses = `${mobileMinHeight} md:min-h-0`;
|
||||
}
|
||||
|
||||
// Timeline layout for zigzag pattern (works best with 3-6 items)
|
||||
if (gridVariant === "timeline" && itemCount >= 3 && itemCount <= 6) {
|
||||
// Convert depth-3d to scale-rotate for timeline (doesn't support 3D)
|
||||
const timelineAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||
|
||||
return (
|
||||
<TimelineBase
|
||||
variant={gridVariant}
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
animationType={timelineAnimationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</TimelineBase>
|
||||
);
|
||||
}
|
||||
|
||||
// Use grid for items below threshold, carousel for items at or above threshold
|
||||
// Timeline with 7+ items will also use carousel
|
||||
const useCarousel = itemCount >= carouselThreshold || (gridVariant === "timeline" && itemCount > 6);
|
||||
|
||||
// Grid layout for 1-4 items
|
||||
if (!useCarousel) {
|
||||
return (
|
||||
<GridLayout
|
||||
itemCount={itemCount}
|
||||
gridVariant={gridVariant}
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
gridRowsClassName={gridRowsClassName}
|
||||
itemHeightClassesOverride={itemHeightClassesOverride}
|
||||
animationType={animationType}
|
||||
supports3DAnimation={supports3DAnimation}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
bottomContent={bottomContent}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
gridClassName={gridClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</GridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-scroll carousel for 5+ items
|
||||
if (mode === "auto") {
|
||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||
|
||||
return (
|
||||
<AutoCarousel
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
animationType={carouselAnimationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
bottomContent={bottomContent}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
carouselClassName={carouselClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</AutoCarousel>
|
||||
);
|
||||
}
|
||||
|
||||
// Button-controlled carousel for 5+ items
|
||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||
|
||||
return (
|
||||
<ButtonCarousel
|
||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||
animationType={carouselAnimationType}
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
bottomContent={bottomContent}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
carouselClassName={carouselClassName}
|
||||
carouselItemClassName={carouselItemClassName}
|
||||
controlsClassName={controlsClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{childrenArray}
|
||||
</ButtonCarousel>
|
||||
);
|
||||
};
|
||||
|
||||
CardStack.displayName = "CardStack";
|
||||
|
||||
export default memo(CardStack);
|
||||
92
src/components/cardStack/CardStackTextBox.tsx
Normal file
92
src/components/cardStack/CardStackTextBox.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import TextBox from "@/components/Textbox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { TextBoxProps } from "./types";
|
||||
|
||||
const CardStackTextBox = ({
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
}: TextBoxProps) => {
|
||||
const styles = useMemo(() => {
|
||||
if (textboxLayout === "default") {
|
||||
return {
|
||||
className: cls("flex flex-col gap-3 md:gap-2", textBoxClassName),
|
||||
titleClassName: cls("text-6xl font-medium text-center", titleClassName),
|
||||
descriptionClassName: cls("text-lg leading-tight text-center md:max-w-6/10", descriptionClassName),
|
||||
tagClassName: cls("w-fit px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-0 mx-auto", tagClassName),
|
||||
buttonContainerClassName: cls("flex flex-wrap gap-4 max-md:justify-center mt-1 md:mt-3 justify-center", buttonContainerClassName),
|
||||
center: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (textboxLayout === "inline-image") {
|
||||
return {
|
||||
className: cls("flex flex-col gap-3 md:gap-2", textBoxClassName),
|
||||
titleClassName: cls("text-4xl md:text-5xl font-medium text-center", titleClassName),
|
||||
descriptionClassName: cls("text-lg leading-tight text-center", descriptionClassName),
|
||||
tagClassName: cls("w-fit px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-0 mx-auto", tagClassName),
|
||||
buttonContainerClassName: cls("flex flex-wrap gap-4 max-md:justify-center mt-1 md:mt-3 justify-center", buttonContainerClassName),
|
||||
center: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: textBoxClassName,
|
||||
titleClassName: cls("text-6xl font-medium", titleClassName),
|
||||
descriptionClassName: cls("text-lg leading-tight", descriptionClassName),
|
||||
tagClassName: cls("px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2", tagClassName),
|
||||
buttonContainerClassName: cls("flex flex-wrap gap-4 max-md:justify-center", buttonContainerClassName),
|
||||
center: false,
|
||||
};
|
||||
}, [textboxLayout, textBoxClassName, titleClassName, descriptionClassName, tagClassName, buttonContainerClassName]);
|
||||
|
||||
if (!title && !titleSegments && !description) return null;
|
||||
|
||||
return (
|
||||
<TextBox
|
||||
title={title!}
|
||||
titleSegments={titleSegments}
|
||||
description={description!}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={styles.className}
|
||||
titleClassName={styles.titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={styles.descriptionClassName}
|
||||
tagClassName={styles.tagClassName}
|
||||
buttonContainerClassName={styles.buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
center={styles.center}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CardStackTextBox.displayName = "CardStackTextBox";
|
||||
|
||||
export default memo(CardStackTextBox);
|
||||
187
src/components/cardStack/hooks/useCardAnimation.ts
Normal file
187
src/components/cardStack/hooks/useCardAnimation.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useRef } from "react";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { CardAnimationType, GridVariant } from "../types";
|
||||
import { useDepth3DAnimation } from "./useDepth3DAnimation";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface UseCardAnimationProps {
|
||||
animationType: CardAnimationType | "depth-3d";
|
||||
itemCount: number;
|
||||
isGrid?: boolean;
|
||||
supports3DAnimation?: boolean;
|
||||
gridVariant?: GridVariant;
|
||||
useIndividualTriggers?: boolean;
|
||||
}
|
||||
|
||||
export const useCardAnimation = ({
|
||||
animationType,
|
||||
itemCount,
|
||||
isGrid = true,
|
||||
supports3DAnimation = false,
|
||||
gridVariant,
|
||||
useIndividualTriggers = false
|
||||
}: UseCardAnimationProps) => {
|
||||
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const perspectiveRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomContentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Enable 3D effect only when explicitly supported and conditions are met
|
||||
const { isMobile } = useDepth3DAnimation({
|
||||
itemRefs,
|
||||
containerRef,
|
||||
perspectiveRef,
|
||||
isEnabled: animationType === "depth-3d" && isGrid && supports3DAnimation && gridVariant === "uniform-all-items-equal",
|
||||
});
|
||||
|
||||
// Use scale-rotate as fallback when depth-3d conditions aren't met
|
||||
const effectiveAnimationType =
|
||||
animationType === "depth-3d" && (isMobile || !isGrid || gridVariant !== "uniform-all-items-equal")
|
||||
? "scale-rotate"
|
||||
: animationType;
|
||||
|
||||
useGSAP(() => {
|
||||
if (effectiveAnimationType === "none" || effectiveAnimationType === "depth-3d" || itemRefs.current.length === 0) return;
|
||||
|
||||
const items = itemRefs.current.filter((el) => el !== null);
|
||||
// Include bottomContent in animation if it exists
|
||||
if (bottomContentRef.current) {
|
||||
items.push(bottomContentRef.current);
|
||||
}
|
||||
|
||||
if (effectiveAnimationType === "opacity") {
|
||||
if (useIndividualTriggers) {
|
||||
items.forEach((item) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 1.25,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 1.25,
|
||||
stagger: 0.15,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (effectiveAnimationType === "slide-up") {
|
||||
items.forEach((item, index) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0, yPercent: 15 },
|
||||
{
|
||||
opacity: 1,
|
||||
yPercent: 0,
|
||||
duration: 1,
|
||||
delay: useIndividualTriggers ? 0 : index * 0.15,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: useIndividualTriggers ? item : items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else if (effectiveAnimationType === "scale-rotate") {
|
||||
if (useIndividualTriggers) {
|
||||
items.forEach((item) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ scaleX: 0, rotate: 10 },
|
||||
{
|
||||
scaleX: 1,
|
||||
rotate: 0,
|
||||
duration: 1,
|
||||
ease: "power3",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ scaleX: 0, rotate: 10 },
|
||||
{
|
||||
scaleX: 1,
|
||||
rotate: 0,
|
||||
duration: 1,
|
||||
stagger: 0.15,
|
||||
ease: "power3",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (effectiveAnimationType === "blur-reveal") {
|
||||
if (useIndividualTriggers) {
|
||||
items.forEach((item) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0, filter: "blur(10px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0, filter: "blur(10px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
duration: 1.2,
|
||||
stagger: 0.15,
|
||||
ease: "power2.out",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [effectiveAnimationType, itemCount, useIndividualTriggers]);
|
||||
|
||||
return { itemRefs, containerRef, perspectiveRef, bottomContentRef };
|
||||
};
|
||||
118
src/components/cardStack/hooks/useDepth3DAnimation.ts
Normal file
118
src/components/cardStack/hooks/useDepth3DAnimation.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState, useRef, RefObject } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const ANIMATION_SPEED = 0.05;
|
||||
const ROTATION_SPEED = 0.1;
|
||||
const MOUSE_MULTIPLIER = 0.5;
|
||||
const ROTATION_MULTIPLIER = 0.25;
|
||||
|
||||
interface UseDepth3DAnimationProps {
|
||||
itemRefs: RefObject<(HTMLElement | null)[]>;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
perspectiveRef?: RefObject<HTMLDivElement | null>;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export const useDepth3DAnimation = ({
|
||||
itemRefs,
|
||||
containerRef,
|
||||
perspectiveRef,
|
||||
isEnabled,
|
||||
}: UseDepth3DAnimationProps) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Detect mobile viewport
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 3D mouse-tracking effect (desktop only)
|
||||
useEffect(() => {
|
||||
if (!isEnabled || isMobile) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let isAnimating = true;
|
||||
|
||||
// Apply perspective to the perspective ref (grid) if provided, otherwise to container (section)
|
||||
const perspectiveElement = perspectiveRef?.current || containerRef.current;
|
||||
if (perspectiveElement) {
|
||||
perspectiveElement.style.perspective = "1200px";
|
||||
perspectiveElement.style.transformStyle = "preserve-3d";
|
||||
}
|
||||
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
let isMouseInSection = false;
|
||||
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let currentRotationX = 0;
|
||||
let currentRotationY = 0;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent): void => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
isMouseInSection =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
if (isMouseInSection) {
|
||||
mouseX = (event.clientX / window.innerWidth) * 100 - 50;
|
||||
mouseY = (event.clientY / window.innerHeight) * 100 - 50;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = (): void => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
if (isMouseInSection) {
|
||||
const distX = mouseX * MOUSE_MULTIPLIER - currentX;
|
||||
const distY = mouseY * MOUSE_MULTIPLIER - currentY;
|
||||
currentX += distX * ANIMATION_SPEED;
|
||||
currentY += distY * ANIMATION_SPEED;
|
||||
|
||||
const distRotX = -mouseY * ROTATION_MULTIPLIER - currentRotationX;
|
||||
const distRotY = mouseX * ROTATION_MULTIPLIER - currentRotationY;
|
||||
currentRotationX += distRotX * ROTATION_SPEED;
|
||||
currentRotationY += distRotY * ROTATION_SPEED;
|
||||
} else {
|
||||
currentX += -currentX * ANIMATION_SPEED;
|
||||
currentY += -currentY * ANIMATION_SPEED;
|
||||
currentRotationX += -currentRotationX * ROTATION_SPEED;
|
||||
currentRotationY += -currentRotationY * ROTATION_SPEED;
|
||||
}
|
||||
|
||||
itemRefs.current?.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
ref.style.transform = `translate(${currentX}px, ${currentY}px) rotateX(${currentRotationX}deg) rotateY(${currentRotationY}deg)`;
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
isAnimating = false;
|
||||
};
|
||||
}, [isEnabled, isMobile, itemRefs, containerRef]);
|
||||
|
||||
return { isMobile };
|
||||
};
|
||||
108
src/components/cardStack/hooks/usePhoneAnimations.ts
Normal file
108
src/components/cardStack/hooks/usePhoneAnimations.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export interface TimelinePhoneViewItem {
|
||||
trigger: string;
|
||||
content: React.ReactNode;
|
||||
imageOne?: string;
|
||||
videoOne?: string;
|
||||
imageAltOne?: string;
|
||||
videoAriaLabelOne?: string;
|
||||
imageTwo?: string;
|
||||
videoTwo?: string;
|
||||
imageAltTwo?: string;
|
||||
videoAriaLabelTwo?: string;
|
||||
}
|
||||
|
||||
const getImageAnimationConfig = (itemIndex: number, imageIndex: number) => {
|
||||
const isFirstImage = imageIndex === 0;
|
||||
const isOddItem = itemIndex % 2 === 1;
|
||||
|
||||
if (isFirstImage) {
|
||||
return {
|
||||
from: { xPercent: -200, rotation: -45 },
|
||||
to: { rotation: isOddItem ? 10 : -10 },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: { xPercent: 200, rotation: 45 },
|
||||
to: { rotation: isOddItem ? -10 : 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const usePhoneAnimations = (items: TimelinePhoneViewItem[]) => {
|
||||
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
const animatePhones = (isMobile: boolean) => {
|
||||
items.forEach((item, itemIndex) => {
|
||||
const images = [item.imageOne || item.videoOne, item.imageTwo || item.videoTwo];
|
||||
|
||||
images.forEach((_, imageIndex) => {
|
||||
const refIndex = itemIndex * 2 + imageIndex;
|
||||
const element = isMobile
|
||||
? mobileImageRefs.current[refIndex]
|
||||
: imageRefs.current[refIndex];
|
||||
|
||||
if (element) {
|
||||
const isFirstImage = imageIndex === 0;
|
||||
|
||||
const fromConfig = isMobile
|
||||
? {
|
||||
xPercent: isFirstImage ? -150 : 150,
|
||||
rotation: isFirstImage ? -25 : 25,
|
||||
}
|
||||
: getImageAnimationConfig(itemIndex, imageIndex).from;
|
||||
|
||||
const toConfig = isMobile
|
||||
? {
|
||||
xPercent: 0,
|
||||
rotation: 0,
|
||||
duration: 1,
|
||||
scrollTrigger: {
|
||||
trigger: element,
|
||||
start: "top 90%",
|
||||
end: "top 50%",
|
||||
scrub: 1,
|
||||
},
|
||||
}
|
||||
: {
|
||||
xPercent: 0,
|
||||
rotation: getImageAnimationConfig(itemIndex, imageIndex).to
|
||||
.rotation,
|
||||
scrollTrigger: {
|
||||
trigger: `.${item.trigger}`,
|
||||
start: "top bottom",
|
||||
end: "top top",
|
||||
scrub: 1,
|
||||
},
|
||||
};
|
||||
|
||||
gsap.fromTo(element, fromConfig, toConfig);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
mm.add("(max-width: 767px)", () => animatePhones(true));
|
||||
mm.add("(min-width: 768px)", () => animatePhones(false));
|
||||
|
||||
return () => {
|
||||
mm.revert();
|
||||
imageRefs.current = [];
|
||||
mobileImageRefs.current = [];
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
imageRefs,
|
||||
mobileImageRefs,
|
||||
};
|
||||
};
|
||||
40
src/components/cardStack/hooks/usePrevNextButtons.ts
Normal file
40
src/components/cardStack/hooks/usePrevNextButtons.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
|
||||
export const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => {
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
|
||||
|
||||
const onPrevButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
const onNextButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev());
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onSelect(emblaApi);
|
||||
emblaApi.on("reInit", onSelect).on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("reInit", onSelect).off("select", onSelect);
|
||||
};
|
||||
}, [emblaApi, onSelect]);
|
||||
|
||||
return {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
};
|
||||
};
|
||||
30
src/components/cardStack/hooks/useScrollProgress.ts
Normal file
30
src/components/cardStack/hooks/useScrollProgress.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
|
||||
export const useScrollProgress = (emblaApi: EmblaCarouselType | undefined) => {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
const onScroll = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
const progress = Math.max(0, Math.min(1, emblaApi.scrollProgress()));
|
||||
setScrollProgress(progress * 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
onScroll(emblaApi);
|
||||
emblaApi
|
||||
.on("reInit", onScroll)
|
||||
.on("scroll", onScroll)
|
||||
.on("slideFocus", onScroll);
|
||||
|
||||
return () => {
|
||||
emblaApi
|
||||
.off("reInit", onScroll)
|
||||
.off("scroll", onScroll)
|
||||
.off("slideFocus", onScroll);
|
||||
};
|
||||
}, [emblaApi, onScroll]);
|
||||
|
||||
return scrollProgress;
|
||||
};
|
||||
243
src/components/cardStack/hooks/useTimelineHorizontal.ts
Normal file
243
src/components/cardStack/hooks/useTimelineHorizontal.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
PROGRESS_DURATION: 5000,
|
||||
TRANSITION_DURATION: 500,
|
||||
ANIMATION_START_DELAY: 100,
|
||||
IMAGE_TRANSITION_DELAY: 300,
|
||||
} as const;
|
||||
|
||||
export interface MediaItem {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
imageAlt?: string;
|
||||
videoAriaLabel?: string;
|
||||
}
|
||||
|
||||
interface UseTimelineHorizontalProps {
|
||||
itemCount: number;
|
||||
mediaItems?: MediaItem[];
|
||||
}
|
||||
|
||||
export const useTimelineHorizontal = ({ itemCount, mediaItems }: UseTimelineHorizontalProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [imageOpacity, setImageOpacity] = useState(1);
|
||||
const [currentMediaSrc, setCurrentMediaSrc] = useState<{ imageSrc?: string; videoSrc?: string }>(() => {
|
||||
if (mediaItems && mediaItems[0]) {
|
||||
return {
|
||||
imageSrc: mediaItems[0].imageSrc,
|
||||
videoSrc: mediaItems[0].videoSrc,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const progressRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const imageTransitionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(false);
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
const resetAllProgressBars = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
progressRefs.current.forEach((bar) => {
|
||||
if (bar) {
|
||||
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
|
||||
bar.style.transform = "scaleX(0)";
|
||||
|
||||
setTimeout(() => {
|
||||
if (bar) {
|
||||
bar.style.transition = "none";
|
||||
}
|
||||
}, ANIMATION_CONFIG.TRANSITION_DURATION);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const animateProgress = useCallback(
|
||||
(index: number) => {
|
||||
if (!progressRefs.current[index]) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
const progressBar = progressRefs.current[index];
|
||||
progressBar.style.transition = "none";
|
||||
progressBar.style.transform = "scaleX(0)";
|
||||
|
||||
const easeInOut = (t: number): number => {
|
||||
return -(Math.cos(Math.PI * t) - 1) / 2;
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
let startTime: number | null = null;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
const linearProgress = Math.min(elapsed / ANIMATION_CONFIG.PROGRESS_DURATION, 1);
|
||||
const easedProgress = easeInOut(linearProgress);
|
||||
|
||||
if (progressRefs.current[index]) {
|
||||
progressRefs.current[index]!.style.transform = `scaleX(${easedProgress})`;
|
||||
}
|
||||
|
||||
if (linearProgress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setActiveIndex((prevIndex) => {
|
||||
const nextIndex = prevIndex + 1;
|
||||
if (nextIndex >= itemCount) {
|
||||
resetAllProgressBars();
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}, ANIMATION_CONFIG.ANIMATION_START_DELAY);
|
||||
},
|
||||
[itemCount, resetAllProgressBars]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = 0; i < activeIndex; i++) {
|
||||
const bar = progressRefs.current[i];
|
||||
if (bar) {
|
||||
bar.style.transform = "scaleX(1)";
|
||||
}
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
animateProgress(activeIndex);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeIndex, animateProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (!hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
|
||||
if (mediaItems && mediaItems[0]) {
|
||||
setCurrentMediaSrc({
|
||||
imageSrc: mediaItems[0].imageSrc,
|
||||
videoSrc: mediaItems[0].videoSrc,
|
||||
});
|
||||
setImageOpacity(1);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
animateProgress(0);
|
||||
}
|
||||
}, ANIMATION_CONFIG.ANIMATION_START_DELAY);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (imageTransitionTimeoutRef.current) {
|
||||
clearTimeout(imageTransitionTimeoutRef.current);
|
||||
imageTransitionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [animateProgress, mediaItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMountedRef.current || !mediaItems) return;
|
||||
|
||||
const currentItem = mediaItems[activeIndex];
|
||||
if (!currentItem) return;
|
||||
|
||||
const newMediaSrc = {
|
||||
imageSrc: currentItem.imageSrc,
|
||||
videoSrc: currentItem.videoSrc,
|
||||
};
|
||||
|
||||
if (
|
||||
(newMediaSrc.imageSrc && newMediaSrc.imageSrc !== currentMediaSrc.imageSrc) ||
|
||||
(newMediaSrc.videoSrc && newMediaSrc.videoSrc !== currentMediaSrc.videoSrc)
|
||||
) {
|
||||
if (imageTransitionTimeoutRef.current) {
|
||||
clearTimeout(imageTransitionTimeoutRef.current);
|
||||
}
|
||||
|
||||
setImageOpacity(0);
|
||||
|
||||
imageTransitionTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setCurrentMediaSrc(newMediaSrc);
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setImageOpacity(1);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, ANIMATION_CONFIG.IMAGE_TRANSITION_DELAY);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (imageTransitionTimeoutRef.current) {
|
||||
clearTimeout(imageTransitionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [activeIndex, mediaItems, currentMediaSrc]);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setImageOpacity(1);
|
||||
}, []);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(index: number) => {
|
||||
if (index === activeIndex) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const bar = progressRefs.current[i];
|
||||
if (bar) {
|
||||
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
|
||||
bar.style.transform = "scaleX(1)";
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = index; i < progressRefs.current.length; i++) {
|
||||
const bar = progressRefs.current[i];
|
||||
if (bar) {
|
||||
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
|
||||
bar.style.transform = "scaleX(0)";
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex(index);
|
||||
},
|
||||
[activeIndex]
|
||||
);
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
progressRefs,
|
||||
handleItemClick,
|
||||
imageOpacity,
|
||||
currentMediaSrc,
|
||||
handleImageLoad,
|
||||
};
|
||||
};
|
||||
144
src/components/cardStack/layouts/carousels/AngledCarousel.tsx
Normal file
144
src/components/cardStack/layouts/carousels/AngledCarousel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import MediaContent from "@/components/shared/MediaContent";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface AngledCarouselItem {
|
||||
id: string;
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
imageAlt?: string;
|
||||
videoAriaLabel?: string;
|
||||
}
|
||||
|
||||
interface AngledCarouselProps {
|
||||
items: AngledCarouselItem[];
|
||||
className?: string;
|
||||
autoPlay?: boolean;
|
||||
autoPlayInterval?: number;
|
||||
}
|
||||
|
||||
const CARD_TRANSITION_DURATION = 0.8;
|
||||
const CARD_TRANSITION_EASE = [0.65, 0, 0.35, 1] as const;
|
||||
|
||||
const cardVariants = {
|
||||
'hidden-0': { opacity: 0, y: '25px' },
|
||||
'hidden-1': { scale: 0.88, opacity: 0, x: 'calc(100% + 20px)', y: '5%', rotate: 2 },
|
||||
'hidden--1': { scale: 0.88, opacity: 0, x: 'calc(-100% - 20px)', y: '5%', rotate: -2 },
|
||||
'0': { scale: 1, opacity: 1, x: '0%', y: '0%', rotate: 0 },
|
||||
'1': { scale: 0.88, opacity: 1, x: '100%', y: '5%', rotate: 2 },
|
||||
'-1': { scale: 0.88, opacity: 1, x: '-100%', y: '5%', rotate: -2 },
|
||||
'2': { scale: 0.8, opacity: 0, x: '200%', y: '10%', rotate: 4 },
|
||||
'-2': { scale: 0.8, opacity: 0, x: '-200%', y: '10%', rotate: -4 },
|
||||
};
|
||||
|
||||
const AngledCarousel = ({ items, className = "", autoPlay = true, autoPlayInterval = 4000 }: AngledCarouselProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isFirstRender, setIsFirstRender] = useState(true);
|
||||
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const n = items.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIsFirstRender(false);
|
||||
}, CARD_TRANSITION_DURATION * 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isFirstRender]);
|
||||
|
||||
const resetAutoPlay = () => {
|
||||
if (autoPlayRef.current) {
|
||||
clearInterval(autoPlayRef.current);
|
||||
}
|
||||
if (autoPlay) {
|
||||
autoPlayRef.current = setInterval(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % n);
|
||||
}, autoPlayInterval);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetAutoPlay();
|
||||
return () => {
|
||||
if (autoPlayRef.current) {
|
||||
clearInterval(autoPlayRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoPlay, autoPlayInterval]);
|
||||
|
||||
const positionFactors = [-2, -1, 0, 1, 2];
|
||||
|
||||
return (
|
||||
<div className={cls("relative w-full flex justify-center items-center overflow-hidden", className)}>
|
||||
<div className="w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] opacity-0" />
|
||||
{positionFactors.map((positionFactor) => {
|
||||
const itemIndex = (activeIndex + positionFactor + n) % n;
|
||||
const item = items[itemIndex];
|
||||
const isCenter = positionFactor === 0;
|
||||
const isVisible = Math.abs(positionFactor) <= 1;
|
||||
|
||||
const getAnimateState = () => {
|
||||
const key = positionFactor.toString() as keyof typeof cardVariants;
|
||||
return cardVariants[key];
|
||||
};
|
||||
|
||||
const getInitialState = () => {
|
||||
if (isVisible && isFirstRender) {
|
||||
const key = `hidden-${positionFactor}` as keyof typeof cardVariants;
|
||||
return cardVariants[key];
|
||||
}
|
||||
return getAnimateState();
|
||||
};
|
||||
|
||||
const getDelay = () => {
|
||||
if (isVisible && isFirstRender) {
|
||||
const delays: { [key: string]: number } = { '-1': 0.6, '0': 0.45, '1': 0.6 };
|
||||
return delays[positionFactor.toString()] || 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
className="!absolute w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] card p-1 rounded-theme-capped overflow-hidden"
|
||||
style={{
|
||||
zIndex: positionFactor === 0 ? 10 : 5 - Math.abs(positionFactor),
|
||||
}}
|
||||
initial={getInitialState()}
|
||||
animate={getAnimateState()}
|
||||
transition={{
|
||||
duration: CARD_TRANSITION_DURATION,
|
||||
ease: CARD_TRANSITION_EASE,
|
||||
delay: getDelay(),
|
||||
}}
|
||||
>
|
||||
<MediaContent
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
imageAlt={item.imageAlt}
|
||||
videoAriaLabel={item.videoAriaLabel}
|
||||
imageClassName="w-full h-full rounded-theme-capped object-cover"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-background/50 backdrop-blur-[1px] pointer-events-none select-none"
|
||||
initial={{ opacity: isCenter ? 0 : 1 }}
|
||||
animate={{ opacity: isCenter ? 0 : 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AngledCarousel.displayName = "AngledCarousel";
|
||||
|
||||
export default memo(AngledCarousel);
|
||||
144
src/components/cardStack/layouts/carousels/ArrowCarousel.tsx
Normal file
144
src/components/cardStack/layouts/carousels/ArrowCarousel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children, useCallback, useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { EmblaCarouselType } from "embla-carousel";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ArrowCarouselProps } from "../../types";
|
||||
|
||||
const ArrowCarousel = ({
|
||||
children,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel = "Carousel section",
|
||||
}: ArrowCarouselProps) => {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const childrenArray = Children.toArray(children);
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setSelectedIndex(emblaApi.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 (
|
||||
<section
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
|
||||
<div className="w-content-width mx-auto">
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cls(
|
||||
"overflow-hidden w-full relative z-10 mask-fade-x",
|
||||
carouselClassName
|
||||
)}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex w-full">
|
||||
{childrenArray.map((child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-none w-60 md:w-40 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={cls("absolute inset-y-0 w-content-width mx-auto left-0 right-0 flex items-center justify-between pointer-events-none z-10", controlsClassName)}>
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
className="pointer-events-auto primary-button h-8 w-auto aspect-square rounded-theme flex items-center justify-center cursor-pointer"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft className="w-4/10 h-4/10 text-primary-cta-text" />
|
||||
</button>
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
className="pointer-events-auto primary-button h-8 w-auto aspect-square rounded-theme flex items-center justify-center cursor-pointer"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight className="w-4/10 h-4/10 text-primary-cta-text" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
ArrowCarousel.displayName = "ArrowCarousel";
|
||||
|
||||
export default memo(ArrowCarousel);
|
||||
148
src/components/cardStack/layouts/carousels/AutoCarousel.tsx
Normal file
148
src/components/cardStack/layouts/carousels/AutoCarousel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { memo, Children } from "react";
|
||||
import Marquee from "react-fast-marquee";
|
||||
import CardStackTextBox from "../../CardStackTextBox";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { AutoCarouselProps } from "../../types";
|
||||
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
||||
|
||||
const AutoCarousel = ({
|
||||
children,
|
||||
uniformGridCustomHeightClasses,
|
||||
animationType,
|
||||
speed = 50,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout = "default",
|
||||
useInvertedBackground,
|
||||
bottomContent,
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
carouselClassName = "",
|
||||
itemClassName = "",
|
||||
textBoxClassName = "",
|
||||
titleClassName = "",
|
||||
titleImageWrapperClassName = "",
|
||||
titleImageClassName = "",
|
||||
descriptionClassName = "",
|
||||
tagClassName = "",
|
||||
buttonContainerClassName = "",
|
||||
buttonClassName = "",
|
||||
buttonTextClassName = "",
|
||||
ariaLabel,
|
||||
showTextBox = true,
|
||||
dualMarquee = false,
|
||||
topMarqueeDirection = "left",
|
||||
bottomCarouselClassName = "",
|
||||
marqueeGapClassName = "",
|
||||
}: AutoCarouselProps) => {
|
||||
const childrenArray = Children.toArray(children);
|
||||
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
|
||||
const { itemRefs, bottomContentRef } = useCardAnimation({
|
||||
animationType,
|
||||
itemCount: childrenArray.length,
|
||||
isGrid: false
|
||||
});
|
||||
|
||||
// Bottom marquee direction is opposite of top
|
||||
const bottomMarqueeDirection = topMarqueeDirection === "left" ? "right" : "left";
|
||||
|
||||
// Reverse order for bottom marquee to avoid alignment with top
|
||||
const bottomChildren = dualMarquee ? [...childrenArray].reverse() : [];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative py-20 w-full",
|
||||
useInvertedBackground && "bg-foreground",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
aria-live="off"
|
||||
>
|
||||
<div className={cls("w-full md:w-content-width mx-auto", containerClassName)}>
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{showTextBox && (title || titleSegments || description) && (
|
||||
<CardStackTextBox
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={titleClassName}
|
||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||
titleImageClassName={titleImageClassName}
|
||||
descriptionClassName={descriptionClassName}
|
||||
tagClassName={tagClassName}
|
||||
buttonContainerClassName={buttonContainerClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
buttonTextClassName={buttonTextClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"w-full flex flex-col",
|
||||
marqueeGapClassName || "gap-6"
|
||||
)}
|
||||
>
|
||||
{/* Top/Single Marquee */}
|
||||
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", carouselClassName)}>
|
||||
<Marquee gradient={false} speed={speed} direction={topMarqueeDirection}>
|
||||
{Children.map(childrenArray, (child, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
|
||||
{/* Bottom Marquee (only if dualMarquee is true) - Reversed order, opposite direction */}
|
||||
{dualMarquee && (
|
||||
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", bottomCarouselClassName || carouselClassName)}>
|
||||
<Marquee gradient={false} speed={speed} direction={bottomMarqueeDirection}>
|
||||
{Children.map(bottomChildren, (child, index) => (
|
||||
<div
|
||||
key={`bottom-${index}`}
|
||||
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bottomContent && (
|
||||
<div ref={bottomContentRef}>
|
||||
{bottomContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
AutoCarousel.displayName = "AutoCarousel";
|
||||
|
||||
export default memo(AutoCarousel);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user