diff --git a/app/explore/enterprise/organisations/OrgCard.tsx b/app/explore/enterprise/organisations/OrgCard.tsx index 32a614c1f..cfcc0385a 100644 --- a/app/explore/enterprise/organisations/OrgCard.tsx +++ b/app/explore/enterprise/organisations/OrgCard.tsx @@ -26,7 +26,7 @@ export default function OrgCard({ return (
{org.bannerImage && ( diff --git a/app/explore/enterprise/organisations/[orgSlug]/page.tsx b/app/explore/enterprise/organisations/[orgSlug]/page.tsx index 77bb428bc..dc4814d6d 100644 --- a/app/explore/enterprise/organisations/[orgSlug]/page.tsx +++ b/app/explore/enterprise/organisations/[orgSlug]/page.tsx @@ -219,7 +219,7 @@ function ExpertMiniCard({ return (
-

+

{expert.user.name}

{expert.isVerified && ( @@ -295,9 +295,9 @@ export default async function OrgProfilePage({ >[]; return ( -
+
{/* Banner — decorative dark cover header */} -
+
{org.bannerImage && ( )} -
-
+
-
- {/* Back link */} -
- - - All Organisations - + {/* Back link lives inside the banner so the header card owns the fold */} +
+
+ + + All Organisations + +
+
+
{/* Org header card */} -
-
+
+
{/* Logo */}
{org.logo ? ( diff --git a/app/explore/enterprise/organisations/page.tsx b/app/explore/enterprise/organisations/page.tsx index fa1afe204..9bffebfca 100644 --- a/app/explore/enterprise/organisations/page.tsx +++ b/app/explore/enterprise/organisations/page.tsx @@ -2,6 +2,7 @@ import { Sparkles } from "lucide-react"; import type { Metadata } from "next"; import { Suspense } from "react"; +import { SpotlightGrid } from "@/components/motion"; import { DEFAULT_ORGANISATION_FILTERS, getOrganisationsMetadata, @@ -58,28 +59,26 @@ async function OrganisationsDirectory() { export default function ExploreOrganisationsPage() { return (
- {/* Hero */} -
-
-
-
-
-
+ {/* Hero — same left-aligned stage as the experts/programs listings */} +
+ +
-
-
- - - Expert Networks & Agencies - +
+
+ + Expert Networks & Agencies
-

+

Explore Organisations

-

- Discover expert networks, consulting agencies, and learning - institutions on Familiarise. Book their curated experts directly. +

+ Expert networks, consulting agencies, and learning institutions — + book their curated experts directly.

diff --git a/app/explore/experts/ExpertsInteractiveContent.tsx b/app/explore/experts/ExpertsInteractiveContent.tsx index 4322e1d34..dc8398560 100644 --- a/app/explore/experts/ExpertsInteractiveContent.tsx +++ b/app/explore/experts/ExpertsInteractiveContent.tsx @@ -147,24 +147,25 @@ export default function ExpertsInteractiveContent({ {/* Search banner */} -
-
-
-
-
+
+
+
-

- Find Your Perfect Expert +

+ Find your perfect expert

-

- Search by name, skill, or specialty to connect with top - consultants +

+ Search by name, skill, or specialty — filters live in the + panel below.

diff --git a/app/explore/experts/[consultantId]/ExpertProfileClient.tsx b/app/explore/experts/[consultantId]/ExpertProfileClient.tsx index 5d1d4bb2f..d496759e0 100644 --- a/app/explore/experts/[consultantId]/ExpertProfileClient.tsx +++ b/app/explore/experts/[consultantId]/ExpertProfileClient.tsx @@ -250,15 +250,15 @@ export function ExpertProfileClient({ }, [currentDate, selectedDate]); return ( -
+
{/* Back Navigation */} -
-
+
+
- + Back to Experts
diff --git a/app/explore/experts/[consultantId]/components/AboutSection.tsx b/app/explore/experts/[consultantId]/components/AboutSection.tsx index 7fcfac7fe..0bc15ae5d 100644 --- a/app/explore/experts/[consultantId]/components/AboutSection.tsx +++ b/app/explore/experts/[consultantId]/components/AboutSection.tsx @@ -27,7 +27,7 @@ export function AboutSection({ consultantDetails, }: AboutSectionProps) { return ( -
+
{/* About */}
diff --git a/app/explore/experts/[consultantId]/components/ExperienceSection.tsx b/app/explore/experts/[consultantId]/components/ExperienceSection.tsx index 247642c6a..d4339591c 100644 --- a/app/explore/experts/[consultantId]/components/ExperienceSection.tsx +++ b/app/explore/experts/[consultantId]/components/ExperienceSection.tsx @@ -150,7 +150,7 @@ export function ExperienceSection({ } return ( -
+
{/* Work Experience */} {workExperiences.length > 0 && (
diff --git a/app/explore/experts/[consultantId]/components/ProfileHeader.tsx b/app/explore/experts/[consultantId]/components/ProfileHeader.tsx index 5231aa8df..14a2f7d50 100644 --- a/app/explore/experts/[consultantId]/components/ProfileHeader.tsx +++ b/app/explore/experts/[consultantId]/components/ProfileHeader.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import { motion } from "framer-motion"; import { Star, MapPin, @@ -13,7 +14,7 @@ import { Twitter, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { SpotlightGrid } from "@/components/motion"; import { User } from "@prisma/client"; import type { ConsultantDetailData } from "../types"; @@ -23,180 +24,176 @@ interface ProfileHeaderProps { reviewCount: number; } +/** + * Cinematic profile stage — a dark panel carrying the expert's identity while + * the sections below stay on the light canvas. Purely presentational: every + * value it shows comes in through props. + */ export function ProfileHeader({ userDetails, consultantDetails, reviewCount, }: ProfileHeaderProps) { + const socials: { label: string; icon: typeof Globe; href: string }[] = [ + userDetails.linkedinUrl && { + label: "LinkedIn", + icon: Linkedin, + href: userDetails.linkedinUrl, + }, + consultantDetails.twitterUrl && { + label: "Twitter", + icon: Twitter, + href: consultantDetails.twitterUrl, + }, + consultantDetails.githubUrl && { + label: "GitHub", + icon: Github, + href: consultantDetails.githubUrl, + }, + consultantDetails.websiteUrl && { + label: "Website", + icon: Globe, + href: consultantDetails.websiteUrl, + }, + ].filter(Boolean) as { label: string; icon: typeof Globe; href: string }[]; + return ( -
-
- {/* Profile Display Image - Square format */} -
- {userDetails.profileDisplayImage ? ( -
- {userDetails.name - {/* Verified Badge */} - {consultantDetails.isVerified && ( -
- -
- )} -
- ) : ( - <> - - + +
+ +
+
+ {/* Portrait */} +
+ {userDetails.profileDisplayImage ? ( +
+ {userDetails.name - - {userDetails.name?.charAt(0) || "E"} - - - {/* Verified Badge */} - {consultantDetails.isVerified && ( -
- -
- )} - - )} -
+
+ ) : ( +
+ {userDetails.name?.charAt(0) || "E"} +
+ )} + {consultantDetails.isVerified && ( +
+ +
+ )} +
- {/* Info */} -
-
-

+ {/* Identity */} +
+

{userDetails.name}

- {consultantDetails.headline && ( - - {consultantDetails.headline} - - )} -
- {/* Rating */} -
-
- {[...Array(5)].map((_, i) => ( - - ))} + {/* Rating */} +
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ + {consultantDetails.rating.toFixed(1)} + + · + + {reviewCount} reviews +
- - {consultantDetails.rating.toFixed(1)} - - - {reviewCount} reviews -
- {/* Meta */} -
-
- - {consultantDetails.domain.name} -
-
- - {consultantDetails.experience} experience + {/* Meta */} +
+ + + {consultantDetails.domain.name} + + + + {consultantDetails.experience} experience + + {userDetails.timezone && ( + + + {userDetails.timezone} + + )}
- {userDetails.timezone && ( -
- - {userDetails.timezone} + + {/* Tags */} + {(consultantDetails.subDomains?.length || + consultantDetails.tags?.length) && ( +
+ {consultantDetails.headline && ( + + {consultantDetails.headline} + + )} + {consultantDetails.subDomains?.map((subdomain) => ( + + {subdomain.name} + + ))} + {consultantDetails.tags?.slice(0, 4).map((tag) => ( + + {tag.name} + + ))}
)} -
- {/* Tags */} -
- {consultantDetails.subDomains?.map((subdomain) => ( - - {subdomain.name} - - ))} - {consultantDetails.tags?.slice(0, 4).map((tag) => ( - - {tag.name} - - ))} + {/* Socials */} + {socials.length > 0 && ( +
+ {socials.map(({ label, icon: Icon, href }) => ( + + + {label} + + ))} +
+ )}
- - {/* Social Links */} - {(userDetails.linkedinUrl || - consultantDetails.twitterUrl || - consultantDetails.githubUrl || - consultantDetails.websiteUrl) && ( -
- {userDetails.linkedinUrl && ( - - - LinkedIn - - )} - {consultantDetails.twitterUrl && ( - - - Twitter - - )} - {consultantDetails.githubUrl && ( - - - GitHub - - )} - {consultantDetails.websiteUrl && ( - - - Website - - )} -
- )}
-
+ ); } diff --git a/app/explore/experts/[consultantId]/components/ReviewsSection.tsx b/app/explore/experts/[consultantId]/components/ReviewsSection.tsx index 644df898e..fb487e178 100644 --- a/app/explore/experts/[consultantId]/components/ReviewsSection.tsx +++ b/app/explore/experts/[consultantId]/components/ReviewsSection.tsx @@ -15,7 +15,7 @@ export function ReviewsSection({ reviews }: ReviewsSectionProps) { : 0; return ( -
+
diff --git a/app/explore/experts/components/ConsultantCard.tsx b/app/explore/experts/components/ConsultantCard.tsx index 576ddb399..31346e637 100644 --- a/app/explore/experts/components/ConsultantCard.tsx +++ b/app/explore/experts/components/ConsultantCard.tsx @@ -203,8 +203,8 @@ export const ConsultantCard = memo(function ConsultantCard({ }); return ( -
-
+
+
{/* Left Section: Consultant Info. Uses a stretched overlay (absolute inset-0) instead of wrapping the whole section, so the nested org-badge link stays valid HTML (no inside ) while @@ -216,17 +216,16 @@ export const ConsultantCard = memo(function ConsultantCard({ className="absolute inset-0 z-0" /> {/* Header */} -
-
+
+
{`Portrait - {/* TODO: Add real presence indicator when online tracking is implemented */}
{/* Name and org badge share one row, so neither may wrap: a long @@ -235,7 +234,7 @@ export const ConsultantCard = memo(function ConsultantCard({ too. Both truncate instead, and the badge keeps its `title` so the full name is still reachable on hover. */}
-

+

{consultant.user.name}

{consultant.isVerified && ( @@ -379,15 +378,15 @@ export const ConsultantCard = memo(function ConsultantCard({ {/* Right Section: Subscription Plans & Actions */}
-
+
{sortedPlans.length > 0 ? ( - + {sortedPlans.map((plan, index) => ( {tabLabels[index]} @@ -417,7 +416,7 @@ export const ConsultantCard = memo(function ConsultantCard({
diff --git a/app/explore/experts/components/DomainGrid.tsx b/app/explore/experts/components/DomainGrid.tsx index 954503370..3d09c1b51 100644 --- a/app/explore/experts/components/DomainGrid.tsx +++ b/app/explore/experts/components/DomainGrid.tsx @@ -56,12 +56,12 @@ function DomainGridImpl({ - - -
- -
-

- ); -} diff --git a/components/home/BenefitsSection.tsx b/components/home/BenefitsSection.tsx index 08c5dc4d9..5fe6d6f1e 100644 --- a/components/home/BenefitsSection.tsx +++ b/components/home/BenefitsSection.tsx @@ -1,8 +1,10 @@ "use client"; import { motion } from "framer-motion"; -import { CheckCircle2 } from "lucide-react"; +import { ArrowUpRight } from "lucide-react"; +import Link from "next/link"; +import { RevealGroup, RevealItem, Reveal } from "@/components/motion"; import { Badge } from "@/components/ui/badge"; import { renderLCPImage } from "@/utils/image"; import type { SupabaseImageFile } from "@/lib/supabase"; @@ -14,91 +16,73 @@ interface BenefitsSectionProps { export function BenefitsSection({ images }: BenefitsSectionProps) { return ( -
-
+
+
+
+ {/* Sticky editorial column */} +
+ + + Why Familiarise + +

+ Years of insight,{" "} + compressed into hours +

+

+ Join thousands of professionals accelerating their careers + through personalised mentorship and expert guidance. +

+ + Start learning today + + +
- {/* Decorative elements */} -
-
- -
-
- - - Why Familiarise? - -

- Transform your career with{" "} - expert guidance -

-

- Join thousands of professionals who have accelerated their careers - through personalized mentorship and expert guidance. -

+ {/* Session imagery */} + +
+ {renderLCPImage(images, 0, "/placeholder.svg", 600, 400)} +
+
+
-
- {BENEFITS.map((benefit, index) => ( - -
- -
-
-

+ {/* Benefits ledger */} + + {BENEFITS.map((benefit, index) => ( + +
+ + {String(index + 1).padStart(2, "0")} + +
+

+ {benefit.title}

-

+

{benefit.description}

- - ))} -
- - - -
- {renderLCPImage(images, 0, "/placeholder.svg", 600, 400)} -
- {/* Floating card decoration */} -
-
-
-
-
-

- Session Complete -

-

- Great progress today! -

-
-
-
-
+
+ ))} +

+ + {/* Soft ambient wash */} +
); } diff --git a/components/home/CTAFinaleSection.tsx b/components/home/CTAFinaleSection.tsx new file mode 100644 index 000000000..1149fe2d1 --- /dev/null +++ b/components/home/CTAFinaleSection.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ArrowRight, Building2, Mic } from "lucide-react"; +import Link from "next/link"; + +import { Reveal } from "@/components/motion"; + +const PATHS = [ + { + icon: Mic, + eyebrow: "For experts", + title: "Teach what you know", + description: + "Set your rates, choose your schedule, and build your brand on a platform that handles payments, scheduling and delivery for you.", + cta: "Apply as an expert", + href: "/become-an-expert", + links: [ + { label: "Expert networks", href: "/explore/enterprise/organisations" }, + ], + }, + { + icon: Building2, + eyebrow: "For teams", + title: "Upskill your organisation", + description: + "One plan covering every seat, sponsored sessions with budgets, GST-compliant invoicing — or run your own expert network.", + cta: "Explore for enterprise", + href: "/enterprise", + links: [{ label: "Browse organisations", href: "/explore/enterprise/organisations" }], + }, +]; + +/** + * Closing CTA — a split stage where the hovered panel brightens while its + * sibling dims. Replaces the separate BecomeExpert/Enterprise sections so the + * page ends with one decisive "which side are you on?" choice. + */ +export function CTAFinaleSection() { + return ( +
+
+ +

Join Familiarise

+
+ {PATHS.map((path, i) => ( + + {/* Hover glow */} +
+
+ + + {path.eyebrow} + +

+ {path.title} +

+

+ {path.description} +

+
+ +
+ + {path.cta} + + + {path.links.map((link) => ( + + {link.label} + + ))} +
+ + ))} +
+ +
+
+ ); +} diff --git a/components/home/CategoriesSection.tsx b/components/home/CategoriesSection.tsx index 540c34e92..206d2d872 100644 --- a/components/home/CategoriesSection.tsx +++ b/components/home/CategoriesSection.tsx @@ -1,108 +1,124 @@ "use client"; +import { useRef } from "react"; import { motion } from "framer-motion"; -import { ChevronRight, LucideIcon } from "lucide-react"; +import { LucideIcon } from "lucide-react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { CATEGORIES } from "./data"; -function CategoryCard({ +function CategoryTile({ category, index, }: { - category: { icon: LucideIcon; name: string; count: string; color: string }; + category: { icon: LucideIcon; name: string; count: string }; index: number; }) { const Icon = category.icon; return ( - - - - -
- -
-
-

- {category.name} -

-

- {category.count} -

-
- -
-
- -
+ {/* Invert on hover */} +
+
+ + + {String(index + 1).padStart(2, "0")} + +
+
+

+ {category.name} +

+

+ {category.count} +

+
+ ); } export function CategoriesSection() { - return ( -
-
+ const railRef = useRef(null); -
+ return ( +
+
- - Categories - -

- Browse by expertise -

-

- Find experts in your field and start learning from the best -

+
+ + Categories + +

+ Browse by expertise +

+
+ {/* Scroll affordance for pointer devices; touch users swipe natively */} +
+ {[-1, 1].map((dir) => ( + + ))} +
-
- {CATEGORIES.map((category, index) => ( - - ))} -
- - - - +
+ {CATEGORIES.map((category, index) => ( + + ))} +
diff --git a/components/home/EnterpriseSection.tsx b/components/home/EnterpriseSection.tsx deleted file mode 100644 index 009039289..000000000 --- a/components/home/EnterpriseSection.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { ArrowRight, Building2 } from "lucide-react"; -import Link from "next/link"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; - -import { ENTERPRISE_FEATURES } from "./data"; - -/** - * The B2C page had no path for the buyer who arrives on behalf of an - * organisation — the enterprise subsystem (sponsored bookings, procurement - * invoicing, hosted expert networks) was invisible from the landing page. - */ -export function EnterpriseSection() { - return ( -
-
-
- -
- - - - For teams & organisations - -

- Bring Familiarise to your{" "} - whole team -

-

- Sponsor sessions for your people, run structured mentorship - programs, or host your own experts on the platform — with the - billing and compliance your finance team expects. -

-
- -
- {ENTERPRISE_FEATURES.map((feature, index) => { - const Icon = feature.icon; - return ( - -
- -
-
-

- {feature.title} -

-

- {feature.description} -

-
-
- ); - })} -
- - - - - -
-
- ); -} diff --git a/components/home/FAQSection.tsx b/components/home/FAQSection.tsx index f107e9e16..a9b02f14b 100644 --- a/components/home/FAQSection.tsx +++ b/components/home/FAQSection.tsx @@ -1,67 +1,72 @@ "use client"; import { motion } from "framer-motion"; +import Link from "next/link"; +import { Reveal } from "@/components/motion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { Badge } from "@/components/ui/badge"; import { FAQ_ITEMS } from "./data"; export function FAQSection() { return ( -
+
- - - FAQ - -

- Common questions -

-

- Everything you need to know about getting started -

-
+
+ {/* Editorial column */} + +

+ Common questions +

+

+ Everything you need to know about getting started. Still unsure?{" "} + + Talk to us + + . +

+
- - - {FAQ_ITEMS.map((item, index) => ( - - - - {item.question} - - - - {item.answer} - - - ))} - - + + + {FAQ_ITEMS.map((item, index) => ( + + + + + {String(index + 1).padStart(2, "0")} + + + {item.question} + + + + + {item.answer} + + + ))} + + +
); diff --git a/components/home/FeaturedExpertsSection.tsx b/components/home/FeaturedExpertsSection.tsx index 3c77c3054..675c807de 100644 --- a/components/home/FeaturedExpertsSection.tsx +++ b/components/home/FeaturedExpertsSection.tsx @@ -4,74 +4,73 @@ import { motion } from "framer-motion"; import { ChevronRight, Star } from "lucide-react"; import Link from "next/link"; +import { MarqueeRow, TiltCard } from "@/components/motion"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import type { IConsultantCardData } from "@/types/consultant"; function ExpertCard({ expert }: { expert: IConsultantCardData }) { return ( - - - -
- + + +
+
+ - + {expert.user.name?.charAt(0) ?? "E"} -
-

+
+

{expert.user.name}

-

+

{expert.headline || expert.domain?.name}

-
-
- - +
+
+ + {expert.rating.toFixed(1)} + · + {expert.experience}
- - - {expert.experience} - -
-
- {expert.tags?.slice(0, 3).map((tag) => ( - - {tag.name} - - ))} +
+ {expert.tags?.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} +
- - - +
+ + ); } function ExpertLoadingSkeleton() { return ( -
- +
+
); } @@ -86,63 +85,47 @@ export function FeaturedExpertsSection({ isLoading, }: FeaturedExpertsSectionProps) { return ( -
-
- -
+
+
- + Featured Experts -

+

Learn from{" "} industry leaders

-

- Handpicked professionals ready to guide your journey +

+ Handpicked professionals ready to guide your journey.

-
- {/* Marquee */} -
-
-
- -
- {isLoading ? ( - Array.from({ length: 6 }).map((_, i) => ( - + {/* Pause on hover so cards stay readable and clickable; reduced-motion + users get native scrolling via the globals.css override. */} + + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + )) - ) : ( - <> - {[...experts, ...experts].map((expert, i) => ( - - ))} - - )} -
-
+ : experts.map((expert) => ( + + ))} +
); } diff --git a/components/home/FeaturesSection.tsx b/components/home/FeaturesSection.tsx index 34ff893ad..854cded79 100644 --- a/components/home/FeaturesSection.tsx +++ b/components/home/FeaturesSection.tsx @@ -2,12 +2,16 @@ import { motion } from "framer-motion"; import { LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { RevealGroup, RevealItem } from "@/components/motion"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; import { FEATURES } from "./data"; -function FeatureCard({ +/** Bento layout: generous half-width cells in a 2×2 arrangement. */ +const SPANS = ["", "", "", ""]; + +function OfferingCell({ feature, index, }: { @@ -22,67 +26,97 @@ function FeatureCard({ const Icon = feature.icon; return ( - - -
-
- -
- + + + {/* Hover glow */} +
+ +
+
+
+ +
+ + {String(index + 1).padStart(2, "0")} +
-

+

{feature.title}

-

{feature.description}

- - - +

+ {feature.description} +

+
+ + + Explore + + + + + + ); } export function FeaturesSection() { return ( -
-
- - {/* Accent gradient */} -
- +
- - Our Offerings - -

- Multiple ways to learn & grow -

-

- Choose the format that works best for your learning style and - schedule +

+ + Our Offerings + +

+ Four ways to learn & grow +

+
+

+ Pick the format that fits your learning style and schedule.

-
+ {FEATURES.map((feature, index) => ( - + ))} -
+
); diff --git a/components/home/HeroSection.tsx b/components/home/HeroSection.tsx index af8ca52fd..0a02e37da 100644 --- a/components/home/HeroSection.tsx +++ b/components/home/HeroSection.tsx @@ -1,169 +1,181 @@ "use client"; -import { useCallback } from "react"; -import { motion, useInView } from "framer-motion"; +import { motion } from "framer-motion"; import { ArrowRight, Sparkles } from "lucide-react"; import Link from "next/link"; -import { useRef, useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { STATS } from "./data"; - -function AnimatedNumber({ - value, - suffix = "", -}: { - value: number; - suffix: string; -}) { - const ref = useRef(null); - const isInView = useInView(ref, { once: true }); - const [displayValue, setDisplayValue] = useState(0); - - const animate = useCallback(() => { - const duration = 2000; - const steps = 60; - const increment = value / steps; - let current = 0; - const timer = setInterval(() => { - current += increment; - if (current >= value) { - setDisplayValue(value); - clearInterval(timer); - } else { - setDisplayValue(Math.floor(current)); - } - }, duration / steps); - return () => clearInterval(timer); - }, [value]); - - useEffect(() => { - if (isInView) { - return animate(); - } - }, [isInView, animate]); - - return ( - - {value % 1 !== 0 - ? displayValue.toFixed(1) - : displayValue.toLocaleString()} - {suffix} - - ); -} +import { + AnimatedNumber, + MarqueeRow, + SpotlightGrid, + wordRevealChild, + wordRevealParent, +} from "@/components/motion"; +import { COMPANY_LOGOS, STATS } from "./data"; +/** + * Hero — full-viewport black stage with a cursor-following spotlight grid. + * The headline rises word-by-word from a clipped line (SSR-safe: words are + * split at module scope, not in an effect), stats count up on load, and the + * trusted-by logo cloud rides a marquee along the bottom edge. + */ export function HeroSection() { - return ( -
- {/* Animated gradient orbs */} -
-
-
-
-
- - {/* Grid pattern overlay */} -
+ const headlineTop = ["Learn", "from", "the"]; + const headlineAccent = ["best", "minds"]; + const headlineBottom = ["in", "your", "industry"]; - {/* Spotlight effect */} -
- -
-
- {/* Badge */} - - - Trusted by 10,000+ professionals worldwide - + return ( +
+ {/* Interactive backdrop */} + +
+
- {/* Main headline */} - - Learn from the{" "} - - best minds - -
- in your industry -
+
+
+
+ {/* Availability pill */} + + + + + + Mentors available this week + - {/* Subheadline */} - - Connect with world-class experts for personalized 1-on-1 sessions, - interactive classes, and live webinars. Your career transformation - starts here. - + {/* Headline — word-by-word clip reveal */} + + {[headlineTop, headlineAccent, headlineBottom].map( + (words, lineIdx) => ( + + {lineIdx === 2 && ( + in your industry + )} + {words.map((word, wordIdx) => ( + + ))} + + ), + )} + - {/* CTAs */} - - - {/* The supply-side CTA lives here now that the navbar carries no - CTAs. (It replaces a "Watch Demo" button that had no href and - no handler — it did nothing when clicked.) */} - - + + + - {/* Stats with animated counters */} - - {STATS.map((stat, i) => ( -
- -
{stat.label}
-
- ))} -
+ {/* Stats strip */} + + {STATS.map((stat) => ( +
+ +
+ {stat.label} +
+
+ ))} +
+
- {/* Bottom gradient fade */} -
+ {/* Trusted-by marquee pinned to the hero's base */} + +

+ + Mentors from +

+ + {COMPANY_LOGOS.map((logo) => ( + + {logo} + + ))} + +
+ + {/* Bottom fade into the offerings band */} +
); } diff --git a/components/home/HowItWorksSection.tsx b/components/home/HowItWorksSection.tsx index 10dd56d05..de9c2c74d 100644 --- a/components/home/HowItWorksSection.tsx +++ b/components/home/HowItWorksSection.tsx @@ -1,108 +1,68 @@ "use client"; import { motion } from "framer-motion"; -import { ArrowRight } from "lucide-react"; -import Link from "next/link"; +import { RevealGroup, RevealItem, SpotlightGrid } from "@/components/motion"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { HOW_IT_WORKS } from "./data"; -function HowItWorksStep({ - step, - index, - isLast, -}: { - step: (typeof HOW_IT_WORKS)[0]; - index: number; - isLast: boolean; -}) { - return ( - -
-
-
- {step.step} -
- {!isLast && ( -
- )} -
-
-

- {step.title} -

-

- {step.description} -

-
-
- - ); -} - +/** + * How it works — a numbered progress line across a dark stage. The connector + * draws itself as the steps scroll into view; each step lifts its number chip + * on hover. + */ export function HowItWorksSection() { return ( -
-
- - {/* Decorative circles */} -
-
- +
+
-
- + - - Getting Started - -

- How it works -

-

- Get started in minutes. Our streamlined process makes it easy to - connect with the right expert for your needs. -

- - - -
+ How it works + +

+ From browsing to breakthrough in{" "} + four steps +

+ -
- {HOW_IT_WORKS.map((step, index) => ( - - ))} -
-
+ + {/* Connector line (desktop) */} +
+ {HOW_IT_WORKS.map((step) => ( + +
+
+ + {step.step} + + +
+

+ {step.title} +

+

+ {step.description} +

+
+
+ ))} +
); diff --git a/components/home/LandingHeroSkeleton.tsx b/components/home/LandingHeroSkeleton.tsx index dbded3e40..d88f8cdc6 100644 --- a/components/home/LandingHeroSkeleton.tsx +++ b/components/home/LandingHeroSkeleton.tsx @@ -12,8 +12,8 @@ export function LandingHeroSkeleton() {
- - + +
{[1, 2, 3, 4].map((i) => ( diff --git a/components/home/PlatformFeaturesSection.tsx b/components/home/PlatformFeaturesSection.tsx deleted file mode 100644 index 63c69e9af..000000000 --- a/components/home/PlatformFeaturesSection.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { LucideIcon } from "lucide-react"; - -import { Badge } from "@/components/ui/badge"; -import { PLATFORM_FEATURES } from "./data"; - -function PlatformFeatureCard({ - feature, - index, -}: { - feature: { icon: LucideIcon; title: string; description: string }; - index: number; -}) { - const Icon = feature.icon; - - return ( - -
- -
-

{feature.title}

-

{feature.description}

-
- ); -} - -export function PlatformFeaturesSection() { - return ( -
-
- -
- - - Platform Features - -

- Everything you need to{" "} - succeed -

-

- A seamless experience built for learning and growth -

-
- -
- {PLATFORM_FEATURES.map((feature, index) => ( - - ))} -
-
-
- ); -} diff --git a/components/home/SuccessStoriesSection.tsx b/components/home/SuccessStoriesSection.tsx deleted file mode 100644 index 36418a8ff..000000000 --- a/components/home/SuccessStoriesSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { Rocket } from "lucide-react"; - -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { SUCCESS_STORIES } from "./data"; - -function SuccessStoryCard({ - story, - index, -}: { - story: (typeof SUCCESS_STORIES)[0]; - index: number; -}) { - return ( - - - -
- - - - {story.name.charAt(0)} - - -
-

{story.name}

-

{story.role}

-

Now at {story.company}

-
-
-

- “{story.story}” -

-
- - {story.metric} -
-
-
-
- ); -} - -export function SuccessStoriesSection() { - return ( -
-
- -
-
- -
- - - Success Stories - -

- Real transformations,{" "} - real results -

-

- See how our mentees have achieved their career goals -

-
- -
- {SUCCESS_STORIES.map((story, index) => ( - - ))} -
-
-
- ); -} diff --git a/components/home/TestimonialsSection.tsx b/components/home/TestimonialsSection.tsx index 9717f2435..06bf34a0a 100644 --- a/components/home/TestimonialsSection.tsx +++ b/components/home/TestimonialsSection.tsx @@ -1,59 +1,54 @@ "use client"; import { useMemo } from "react"; -import { motion } from "framer-motion"; import { Star } from "lucide-react"; +import { MarqueeRow, Reveal } from "@/components/motion"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; import type { ReviewWithProfiles } from "@/types/review"; function TestimonialCard({ review }: { review: ReviewWithProfiles }) { return ( - - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-

- “ - {review.reviewDescription || - "Great experience working with this expert!"} - ” -

-
- - - - {review.consulteeProfile?.user?.name?.charAt(0) ?? "U"} - - -
-

- {review.consulteeProfile?.user?.name || "Anonymous"} -

-

- Session with {review.consultantProfile?.user?.name} -

-
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ “ + {review.reviewDescription || + "Great experience working with this expert!"} + ” +
+
+ + + + {review.consulteeProfile?.user?.name?.charAt(0) ?? "U"} + + +
+

+ {review.consulteeProfile?.user?.name || "Anonymous"} +

+

+ Session with {review.consultantProfile?.user?.name} +

- - +
+
); } function TestimonialLoadingSkeleton() { - return ( -
- -
- ); + return
; } interface TestimonialsSectionProps { @@ -72,85 +67,53 @@ export function TestimonialsSection({ [reviews], ); - return ( -
-
+ const skeletons = ( + <> + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ); - {/* Glow accents */} -
-
+ return ( +
+
-
- +
+ Testimonials -

- Loved by professionals +

+ Trusted by professionals{" "} + worldwide

-

- See what our community has to say about their experience +

+ Real stories from real sessions.

- -
- - {/* First marquee row - left to right */} -
-
-
- -
- {isLoading ? ( - Array.from({ length: 4 }).map((_, i) => ( - - )) - ) : ( - <> - {[...displayReviews, ...displayReviews, ...displayReviews].map( - (review, i) => ( - - ), - )} - - )} -
+
- {/* Second marquee row - right to left */} -
-
-
- -
- {isLoading ? ( - Array.from({ length: 4 }).map((_, i) => ( - - )) - ) : ( - <> - {[...displayReviews, ...displayReviews, ...displayReviews] - .reverse() - .map((review, i) => ( - - ))} - - )} -
+ {/* Two counter-drifting rows; both pause on hover for readability */} +
+ + {isLoading ? skeletons : displayReviews.map((r) => ( + + ))} + + + {isLoading + ? skeletons + : [...displayReviews].reverse().map((r) => ( + + ))} +
); diff --git a/components/home/TrustBadgesSection.tsx b/components/home/TrustBadgesSection.tsx deleted file mode 100644 index 4f69cee29..000000000 --- a/components/home/TrustBadgesSection.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { TRUST_BADGES } from "./data"; - -export function TrustBadgesSection() { - return ( -
-
-
- {TRUST_BADGES.map((badge, index) => ( - -
- -
-

{badge.label}

-

{badge.description}

-
- ))} -
-
-
- ); -} diff --git a/components/home/TrustedBySection.tsx b/components/home/TrustedBySection.tsx deleted file mode 100644 index 34467e788..000000000 --- a/components/home/TrustedBySection.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { COMPANY_LOGOS } from "./data"; - -export function TrustedBySection() { - return ( -
-
- - Our experts have worked at leading companies - -
- {COMPANY_LOGOS.map((company, i) => ( - - {company} - - ))} -
-
-
- ); -} diff --git a/components/home/UpcomingEventsSection.tsx b/components/home/UpcomingEventsSection.tsx index a9f920676..69116f620 100644 --- a/components/home/UpcomingEventsSection.tsx +++ b/components/home/UpcomingEventsSection.tsx @@ -1,185 +1,89 @@ "use client"; -import { motion } from "framer-motion"; -import { Calendar, ChevronRight, Clock, Star } from "lucide-react"; +import { ArrowRight, Calendar, Clock, Users } from "lucide-react"; import Link from "next/link"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { RevealGroup, RevealItem, Reveal } from "@/components/motion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import type { ReviewWithProfiles } from "@/types/review"; import { UPCOMING_EVENTS } from "./data"; -function EventCard({ - event, - index, -}: { - event: (typeof UPCOMING_EVENTS)[0]; - index: number; -}) { +/** + * Upcoming events — static curated marketing data rendered on a dark strip + * beneath the testimonials marquee. Self-contained: it no longer duplicates + * the review cards that TestimonialsSection already owns. + */ +export function UpcomingEventsSection() { return ( - - - -
+
+
+
+ {/* Editorial column */} + - {event.type} + Live sessions - - {event.attendees} attending - -
-

- {event.title} -

-

Hosted by {event.host}

-
- - - {event.date} - - - - {event.time} - -
- - - - ); -} - -interface UpcomingEventsSectionProps { - reviews: ReviewWithProfiles[]; -} - -export function UpcomingEventsSection({ reviews }: UpcomingEventsSectionProps) { - const slicedReviews = reviews.slice(0, 3); - - return ( -
-
- - {/* Glow accents */} -
-
- -
-
- {/* Testimonials Column */} -
- - + Something is always{" "} + happening + +

+ Workshops, webinars and cohort classes run every week — join one + or bring your whole team. +

+ + + + -
- {slicedReviews.map((review, i) => ( - + {UPCOMING_EVENTS.map((event) => ( + + - - -
- {Array.from({ length: 5 }).map((_, j) => ( - - ))} -
-

- “{review.reviewDescription || "Great experience!"} - ” -

-
- - - - {review.consulteeProfile?.user?.name?.charAt(0) ?? - "U"} - - -

- — {review.consulteeProfile?.user?.name || "Anonymous"} -

-
-
-
-
- ))} -
-
+
+ + {event.type} + +

+ {event.title} +

+

+ Hosted by {event.host} +

+
- {/* Upcoming Events Column */} -
- - - Coming Soon - -

- Upcoming events -

-
- -
- {UPCOMING_EVENTS.map((event, index) => ( - - ))} -
- - - - - - -
+
+ + + {event.date} + + + + {event.time} + + + + {event.attendees} + + +
+ + + ))} +
diff --git a/components/motion/AnimatedNumber.tsx b/components/motion/AnimatedNumber.tsx new file mode 100644 index 000000000..bef3f7b83 --- /dev/null +++ b/components/motion/AnimatedNumber.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { animate, useInView } from "framer-motion"; + +interface AnimatedNumberProps { + value: number; + suffix?: string; + prefix?: string; + /** Decimal places to render (0.5 ratings etc). */ + decimals?: number; + className?: string; +} + +/** + * Counts from zero to `value` with a single framer-motion tween when the + * element scrolls into view. Replaces the setInterval-based counter that used + * to live inside HeroSection — same behaviour, one animation primitive, and + * it honours MotionConfig reducedMotion="user" for free. + */ +export function AnimatedNumber({ + value, + suffix = "", + prefix = "", + decimals = 0, + className, +}: AnimatedNumberProps) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: "-40px" }); + const [display, setDisplay] = useState(0); + + useEffect(() => { + if (!isInView) return; + const controls = animate(0, value, { + duration: 1.6, + ease: [0.16, 1, 0.3, 1], + onUpdate: setDisplay, + }); + return () => controls.stop(); + }, [isInView, value]); + + return ( + + {prefix} + {display.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} + {suffix} + + ); +} diff --git a/components/motion/MarqueeRow.tsx b/components/motion/MarqueeRow.tsx new file mode 100644 index 000000000..9eb2b06a3 --- /dev/null +++ b/components/motion/MarqueeRow.tsx @@ -0,0 +1,46 @@ +"use client"; + +interface MarqueeRowProps { + children: React.ReactNode; + /** Seconds for one full loop across the track. */ + duration?: number; + reverse?: boolean; + className?: string; + /** Fade the left/right edges of the viewport container. */ + fadeEdges?: boolean; +} + +/** + * Infinite horizontal marquee built on the .animate-marquee keyframes in + * globals.css. The track carries the children twice; translateX(-50%) of the + * doubled track equals exactly one copy width, so the loop is seamless. + * + * Hovering pauses travel so items stay readable/clickable. Under + * prefers-reduced-motion globals.css stops the animation and hands scrolling + * back to the user. + */ +export function MarqueeRow({ + children, + duration = 40, + reverse = false, + className = "", + fadeEdges = true, +}: MarqueeRowProps) { + return ( +
+
+ {children} + {children} +
+
+ ); +} diff --git a/components/motion/MotionRoot.tsx b/components/motion/MotionRoot.tsx new file mode 100644 index 000000000..724fc78f2 --- /dev/null +++ b/components/motion/MotionRoot.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { MotionConfig } from "framer-motion"; + +/** + * Shared motion environment for the marketing surfaces. Wraps children in + * MotionConfig reducedMotion="user" so every framer-motion animation in the + * tree honours the OS reduce-motion setting. + * + * Server components must never import framer-motion directly — their client + * bindings can resolve undefined during hydration ("Element type is + * invalid"). Anything animated gets wrapped by this provider instead. + */ +export function MotionRoot({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/components/motion/Reveal.tsx b/components/motion/Reveal.tsx new file mode 100644 index 000000000..03dde4ce6 --- /dev/null +++ b/components/motion/Reveal.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { motion, type Variants } from "framer-motion"; +import { fadeUp, staggerContainer } from "./presets"; + +type RevealVariant = "fadeUp" | "fadeIn" | "blurIn" | "scaleIn"; + +const VARIANTS: Record = { + fadeUp, + fadeIn: { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.6, ease: "easeOut" } }, + }, + blurIn: { + hidden: { opacity: 0, filter: "blur(8px)", y: 12 }, + visible: { + opacity: 1, + filter: "blur(0px)", + y: 0, + transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] }, + }, + }, + scaleIn: { + hidden: { opacity: 0, scale: 0.94 }, + visible: { + opacity: 1, + scale: 1, + transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] }, + }, + }, +}; + +interface RevealProps { + children: React.ReactNode; + variant?: RevealVariant; + /** Seconds of delay before the reveal starts. */ + delay?: number; + className?: string; + /** Animate every time the element enters the viewport (default: once). */ + repeat?: boolean; +} + +/** + * Scroll-triggered entrance for any block. Renders a plain div until it is in + * view, so content is never trapped invisible if JS fails — the initial state + * only applies once the element mounts as a client component. + */ +export function Reveal({ + children, + variant = "fadeUp", + delay = 0, + className, + repeat = false, +}: RevealProps) { + return ( + + {children} + + ); +} + +/** Parent that staggers its immediate children into view. */ +export function RevealGroup({ + children, + stagger = 0.08, + delayChildren = 0.05, + className, +}: { + children: React.ReactNode; + stagger?: number; + delayChildren?: number; + className?: string; +}) { + return ( + + {children} + + ); +} + +export function RevealItem({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/components/motion/SmoothScroll.tsx b/components/motion/SmoothScroll.tsx new file mode 100644 index 000000000..a129689c3 --- /dev/null +++ b/components/motion/SmoothScroll.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ReactLenis } from "lenis/react"; + +import { MotionRoot } from "./MotionRoot"; + +/** + * Inertial smooth scrolling + shared motion config for the marketing surfaces + * (landing, explore). Scoped per page tree rather than the root layout so + * dashboard/app routes keep native scrolling. Lenis drives window scroll, so + * sticky positioning, anchor links and scroll restoration all keep working. + */ +export function SmoothScroll({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/components/motion/SpotlightGrid.tsx b/components/motion/SpotlightGrid.tsx new file mode 100644 index 000000000..9c92b4388 --- /dev/null +++ b/components/motion/SpotlightGrid.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRef } from "react"; + +/** + * Cursor-following spotlight over a hairline grid. Writes --sx/--sy (px) on + * the container; the .spotlight-grid utility in globals.css paints both the + * masked grid and the soft radial from those variables. Purely decorative — + * pointer-events: none, and static fallback position when untouched. + */ +export function SpotlightGrid({ className = "" }: { className?: string }) { + const ref = useRef(null); + + function onPointerMove(e: React.PointerEvent) { + if (e.pointerType !== "mouse" || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + ref.current.style.setProperty("--sx", `${e.clientX - rect.left}px`); + ref.current.style.setProperty("--sy", `${e.clientY - rect.top}px`); + } + + return ( +
+ ); +} diff --git a/components/motion/TiltCard.tsx b/components/motion/TiltCard.tsx new file mode 100644 index 000000000..2e43f8118 --- /dev/null +++ b/components/motion/TiltCard.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRef, useState } from "react"; +import { + motion, + useMotionTemplate, + useMotionValue, + useSpring, + useTransform, +} from "framer-motion"; + +interface TiltCardProps { + children: React.ReactNode; + className?: string; + /** Max rotation in degrees at the card edge. */ + maxTilt?: number; + /** Render a specular glare that tracks the pointer. */ + glare?: boolean; +} + +/** + * Pointer-tracked 3D tilt with optional moving glare. Springs keep the motion + * physical; on touch devices there is no hover pointer, so the handlers simply + * never fire and the card renders static. Reduced-motion users get a static + * card too via MotionConfig reducedMotion="user". + */ +export function TiltCard({ + children, + className = "", + maxTilt = 8, + glare = true, +}: TiltCardProps) { + const ref = useRef(null); + const [hovering, setHovering] = useState(false); + + const px = useMotionValue(0.5); + const py = useMotionValue(0.5); + + const rotateX = useSpring(useTransform(py, [0, 1], [maxTilt, -maxTilt]), { + stiffness: 180, + damping: 20, + }); + const rotateY = useSpring(useTransform(px, [0, 1], [-maxTilt, maxTilt]), { + stiffness: 180, + damping: 20, + }); + + const glareX = useTransform(px, (v) => `${v * 100}%`); + const glareY = useTransform(py, (v) => `${v * 100}%`); + const glareBg = useMotionTemplate`radial-gradient(circle at ${glareX} ${glareY}, rgba(255,255,255,0.18), transparent 60%)`; + + function onPointerMove(e: React.PointerEvent) { + if (e.pointerType !== "mouse" || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + px.set((e.clientX - rect.left) / rect.width); + py.set((e.clientY - rect.top) / rect.height); + } + + return ( + { + if (e.pointerType === "mouse") setHovering(true); + }} + onPointerLeave={() => { + setHovering(false); + px.set(0.5); + py.set(0.5); + }} + > + {children} + {glare && hovering && ( + + )} + + ); +} diff --git a/components/motion/index.ts b/components/motion/index.ts new file mode 100644 index 000000000..0635d33f8 --- /dev/null +++ b/components/motion/index.ts @@ -0,0 +1,19 @@ +export { AnimatedNumber } from "./AnimatedNumber"; +export { MarqueeRow } from "./MarqueeRow"; +export { MotionRoot } from "./MotionRoot"; +export { Reveal, RevealGroup, RevealItem } from "./Reveal"; +export { SmoothScroll } from "./SmoothScroll"; +export { SpotlightGrid } from "./SpotlightGrid"; +export { TiltCard } from "./TiltCard"; +export { + EASE_OUT_EXPO, + blurIn, + fadeUp, + fadeIn, + scaleIn, + springSnappy, + springSoft, + staggerContainer, + wordRevealChild, + wordRevealParent, +} from "./presets"; diff --git a/components/motion/presets.ts b/components/motion/presets.ts new file mode 100644 index 000000000..18e04450f --- /dev/null +++ b/components/motion/presets.ts @@ -0,0 +1,73 @@ +import type { Transition, Variants } from "framer-motion"; + +/** Signature easing of the overhaul — fast start, long glide. Mirrors --ease-out-expo in globals.css. */ +export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const; + +export const springSoft: Transition = { + type: "spring", + stiffness: 120, + damping: 20, + mass: 0.9, +}; + +export const springSnappy: Transition = { + type: "spring", + stiffness: 400, + damping: 32, +}; + +export const fadeUp: Variants = { + hidden: { opacity: 0, y: 28 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.7, ease: EASE_OUT_EXPO }, + }, +}; + +export const fadeIn: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.6, ease: "easeOut" } }, +}; + +export const blurIn: Variants = { + hidden: { opacity: 0, filter: "blur(8px)", y: 12 }, + visible: { + opacity: 1, + filter: "blur(0px)", + y: 0, + transition: { duration: 0.7, ease: EASE_OUT_EXPO }, + }, +}; + +export const scaleIn: Variants = { + hidden: { opacity: 0, scale: 0.94 }, + visible: { + opacity: 1, + scale: 1, + transition: { duration: 0.6, ease: EASE_OUT_EXPO }, + }, +}; + +export const staggerContainer = ( + stagger = 0.08, + delayChildren = 0.05, +): Variants => ({ + hidden: {}, + visible: { transition: { staggerChildren: stagger, delayChildren } }, +}); + +/** Word-level headline reveal: parent staggers, children rise from a clipped line. */ +export const wordRevealParent: Variants = { + hidden: {}, + visible: { transition: { staggerChildren: 0.045 } }, +}; + +export const wordRevealChild: Variants = { + hidden: { y: "110%", rotate: 4 }, + visible: { + y: "0%", + rotate: 0, + transition: { duration: 0.85, ease: EASE_OUT_EXPO }, + }, +}; diff --git a/package-lock.json b/package-lock.json index 43d0a0945..013be2a74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "date-fns-tz": "^3.2.0", "framer-motion": "^11.15.0", "iso-639-1": "^3.1.5", + "lenis": "^1.3.26", "libsodium-wrappers": "^0.8.2", "lucide-react": "^0.563.0", "next": "^15.5.0", @@ -14518,6 +14519,37 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/lenis": { + "version": "1.3.26", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz", + "integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 80e460a23..1ceeea3c1 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "date-fns-tz": "^3.2.0", "framer-motion": "^11.15.0", "iso-639-1": "^3.1.5", + "lenis": "^1.3.26", "libsodium-wrappers": "^0.8.2", "lucide-react": "^0.563.0", "next": "^15.5.0", diff --git a/tailwind.config.ts b/tailwind.config.ts index 611c3dbd8..9fad6fbb7 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -154,6 +154,16 @@ const config: Config = { "0 2px 4px -2px hsl(var(--shadow-color) / 0.1), 0 4px 8px -2px hsl(var(--shadow-color) / 0.06)", "elevation-3": "0 8px 16px -4px hsl(var(--shadow-color) / 0.12), 0 16px 32px -8px hsl(var(--shadow-color) / 0.08)", + /* UI overhaul: layered card shadows that read as physical elevation + rather than a flat outline glow (shadow-card / shadow-lift / float). */ + card: + "0 1px 1px hsl(var(--shadow-color) / 0.03), 0 2px 6px -1px hsl(var(--shadow-color) / 0.05), 0 8px 24px -6px hsl(var(--shadow-color) / 0.06)", + lift: + "0 2px 4px -1px hsl(var(--shadow-color) / 0.05), 0 12px 28px -6px hsl(var(--shadow-color) / 0.1), 0 32px 64px -16px hsl(var(--shadow-color) / 0.1)", + }, + transitionTimingFunction: { + // Signature easing of the overhaul — fast start, long glide. + "out-expo": "cubic-bezier(0.16, 1, 0.3, 1)", }, keyframes: { "accordion-down": {