Merge version_1_1780757373420 into main #2

Merged
bender merged 2 commits from version_1_1780757373420 into main 2026-06-06 14:52:15 +00:00
2 changed files with 273 additions and 145 deletions

View File

@@ -1,145 +1,107 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import { useButtonClick } from "@/hooks/useButtonClick";
import React, { useRef, useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/utils/cn'; // Assuming this utility exists
const StaggerText = ({ text }: { text: string }) => (
<span className="truncate overflow-hidden">
{[...text].map((char, index) => (
<span
key={index}
className="inline-block transition-transform duration-400 ease-out md:group-hover:-translate-y-[1.25em]"
style={{ textShadow: "0 1.25em currentColor", transitionDelay: `${index * 0.01}s`, whiteSpace: char === " " ? "pre" : undefined }}
>
{char}
</span>
))}
</span>
);
type HeroVideoExpandProps = {
interface HeroVideoExpandProps {
title: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
description: string;
videoSrc: string;
thumbnailSrc?: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
onComplete?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
className?: string;
}
const HeroVideoExpand = ({
title,
description,
videoSrc,
imageSrc,
thumbnailSrc,
primaryButton,
secondaryButton,
onComplete,
className,
}: HeroVideoExpandProps) => {
const [showLoader, setShowLoader] = useState(true);
const [expanded, setExpanded] = useState(false);
const handlePrimaryClick = useButtonClick(primaryButton.href);
const handleSecondaryClick = useButtonClick(secondaryButton.href);
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start start", "end start"],
});
const videoY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
const videoScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
const handlePlayClick = () => {
if (videoRef.current) {
videoRef.current.play();
setIsPlaying(true);
setHasPlayedOnce(true);
}
};
useEffect(() => {
const expandTimer = setTimeout(() => setExpanded(true), 600);
const hideTimer = setTimeout(() => {
setShowLoader(false);
onComplete?.();
}, 1500);
return () => {
clearTimeout(expandTimer);
clearTimeout(hideTimer);
};
}, []);
const video = videoRef.current;
if (video) {
const handleEnded = () => {
setIsPlaying(false);
if (onComplete) {
onComplete();
}
};
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('ended', handleEnded);
};
}
}, [onComplete]);
return (
<>
<AnimatePresence>
{showLoader && (
<motion.div
key="loader"
className="fixed inset-0 z-100 bg-background"
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<motion.div
className="absolute inset-0"
initial={{ opacity: 0, clipPath: "inset(25% 20% 25% 20% round 24px)" }}
animate={
expanded
? { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }
: { opacity: 1, clipPath: "inset(25% 20% 25% 20% round 24px)" }
}
transition={{ duration: expanded ? 1.4 : 1.2, ease: [0.76, 0, 0.24, 1] }}
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
</motion.div>
</motion.div>
)}
</AnimatePresence>
<section ref={sectionRef} aria-label="Hero section" className="relative w-full h-svh overflow-hidden mb-20">
<motion.div className="absolute inset-0" style={{ y: videoY, scale: videoScale }}>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
</motion.div>
<div className="absolute inset-0 bg-linear-to-t from-background/60 via-transparent to-background/0" />
<div className="absolute inset-0 z-10 flex flex-col justify-end pb-8 md:pb-12 xl:pb-16 2xl:pb-20">
<div className="w-content-width mx-auto flex flex-col md:flex-row md:items-end md:justify-between gap-8">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className="w-full"
>
<AutoFillText className="font-medium text-white" paddingY="0">{title}</AutoFillText>
</motion.div>
<div className="flex items-center gap-3">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, delay: 0.1, ease: "easeOut" }}
className="w-1/2 md:w-auto"
>
<a
href={primaryButton.href}
onClick={handlePrimaryClick}
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer primary-button text-primary-cta-text"
>
<StaggerText text={primaryButton.text} />
</a>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, delay: 0.2, ease: "easeOut" }}
className="w-1/2 md:w-auto"
>
<a
href={secondaryButton.href}
onClick={handleSecondaryClick}
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer secondary-button text-secondary-cta-text"
>
<StaggerText text={secondaryButton.text} />
</a>
</motion.div>
</div>
<section className={cn('relative w-full h-screen flex items-center justify-center overflow-hidden', className)}>
<video
ref={videoRef}
src={videoSrc}
poster={thumbnailSrc}
loop={false}
muted={false} // Adjust as needed
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => {
setIsPlaying(false);
// onComplete is handled by useEffect
}}
/>
{!isPlaying && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center p-4 text-center z-10"
>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-4">{title}</h1>
<p className="text-lg md:text-xl text-white/80 max-w-2xl mb-8">{description}</p>
<div className="flex gap-4">
{primaryButton && (
<a href={primaryButton.href} className="px-6 py-3 bg-primary-500 text-white rounded-md text-lg hover:bg-primary-600 transition-colors">
{primaryButton.text}
</a>
)}
{secondaryButton && (
<a href={secondaryButton.href} className="px-6 py-3 border border-white text-white rounded-md text-lg hover:bg-white/10 transition-colors">
{secondaryButton.text}
</a>
)}
</div>
</div>
</section>
</>
{!hasPlayedOnce && (
<button
onClick={handlePlayClick}
className="mt-8 px-8 py-4 bg-white text-gray-900 rounded-full text-xl font-semibold flex items-center gap-2 hover:bg-gray-200 transition-colors"
>
<svg className="h-6 w-6 fill-current" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
Play Video
</button>
)}
</motion.div>
)}
</section>
);
};

View File

@@ -1,21 +1,187 @@
import { resolveIcon } from "./resolve-icon";
import type { IconInput } from "./resolve-icon";
import {
Activity,
ArrowRight,
Calculator,
Calendar,
Camera,
CheckCircle,
ChevronDown,
ChevronRight,
Circle,
Cloud,
Code,
CreditCard,
Database,
Diamond,
DollarSign,
Droplet,
Eye,
FileText,
Fingerprint,
FlaskConical,
Flower,
Footprints,
GalleryVertical,
Gem,
Gift,
Globe,
Hammer,
HardHat,
Heart,
HelpCircle,
Home,
Image,
Info,
Key,
Laptop,
LayoutGrid,
Leaf,
Lightbulb,
Link,
Loader,
Lock,
LucideIcon,
Mail,
MapPin,
Megaphone,
Menu,
MessageSquare,
Mic,
Minus,
Monitor,
Moon,
Mountain,
Package,
Pencil,
Phone,
PieChart,
Pin,
Play,
Plus,
Rocket,
Rss,
Scissors,
Search,
Send,
Settings,
Shield,
ShoppingCart,
Sparkle,
Sprout,
Star,
Sun,
Tablet,
Tag,
Target,
Terminal,
ThumbsUp,
TreeDeciduous,
TrendingUp,
Truck,
User,
Utensils,
Video,
Wallet,
Wand,
Wifi,
X,
Zap,
} from 'lucide-react';
const DynamicIcon = ({
icon,
size,
className,
strokeWidth,
}: {
icon: IconInput;
size?: number;
className?: string;
strokeWidth?: number;
}) => {
const Icon = resolveIcon(icon);
return <Icon size={size} className={className} strokeWidth={strokeWidth} />;
};
export default DynamicIcon;
export { resolveIcon };
export type { IconInput };
// eslint-disable-next-line react-refresh/only-export-components
export function resolveIcon(iconName: string | LucideIcon): LucideIcon {
if (typeof iconName === 'string') {
switch (iconName) {
case 'Activity': return Activity;
case 'ArrowRight': return ArrowRight;
case 'Calculator': return Calculator;
case 'Calendar': return Calendar;
case 'Camera': return Camera;
case 'CheckCircle': return CheckCircle;
case 'ChevronDown': return ChevronDown;
case 'ChevronRight': return ChevronRight;
case 'Circle': return Circle;
case 'Cloud': return Cloud;
case 'Code': return Code;
case 'CreditCard': return CreditCard;
case 'Database': return Database;
case 'Diamond': return Diamond;
case 'DollarSign': return DollarSign;
case 'Droplet': return Droplet;
case 'Eye': return Eye;
case 'FileText': return FileText;
case 'Fingerprint': return Fingerprint;
case 'FlaskConical': return FlaskConical;
case 'Flower': return Flower;
case 'Footprints': return Footprints;
case 'GalleryVertical': return GalleryVertical;
case 'Gem': return Gem;
case 'Gift': return Gift;
case 'Globe': return Globe;
case 'Hammer': return Hammer;
case 'HardHat': return HardHat;
case 'Heart': return Heart;
case 'HelpCircle': return HelpCircle;
case 'Home': return Home;
case 'Image': return Image;
case 'Info': return Info;
case 'Key': return Key;
case 'Laptop': return Laptop;
case 'LayoutGrid': return LayoutGrid;
case 'Leaf': return Leaf;
case 'Lightbulb': return Lightbulb;
case 'Link': return Link;
case 'Loader': return Loader;
case 'Lock': return Lock;
case 'Mail': return Mail;
case 'MapPin': return MapPin;
case 'Megaphone': return Megaphone;
case 'Menu': return Menu;
case 'MessageSquare': return MessageSquare;
case 'Mic': return Mic;
case 'Minus': return Minus;
case 'Monitor': return Monitor;
case 'Moon': return Moon;
case 'Mountain': return Mountain;
case 'Package': return Package;
case 'Pencil': return Pencil;
case 'Phone': return Phone;
case 'PieChart': return PieChart;
case 'Pin': return Pin;
case 'Play': return Play;
case 'Plus': return Plus;
case 'Rocket': return Rocket;
case 'Rss': return Rss;
case 'Scissors': return Scissors;
case 'Search': return Search;
case 'Send': return Send;
case 'Settings': return Settings;
case 'Shield': return Shield;
case 'ShoppingCart': return ShoppingCart;
case 'Sparkle': return Sparkle;
case 'Sprout': return Sprout;
case 'Star': return Star;
case 'Sun': return Sun;
case 'Tablet': return Tablet;
case 'Tag': return Tag;
case 'Target': return Target;
case 'Terminal': return Terminal;
case 'ThumbsUp': return ThumbsUp;
case 'TreeDeciduous': return TreeDeciduous;
case 'TrendingUp': return TrendingUp;
case 'Truck': return Truck;
case 'User': return User;
case 'Utensils': return Utensils;
case 'Video': return Video;
case 'Wallet': return Wallet;
case 'Wand': return Wand;
case 'Wifi': return Wifi;
case 'X': return X;
case 'Zap': return Zap;
default:
// console.warn(`Icon "${iconName}" not found, falling back to Circle.`);
return Circle;
}
}
return iconName;
}