Merge version_2 into main #6

Merged
bender merged 12 commits from version_2 into main 2026-03-04 18:47:03 +00:00
12 changed files with 602 additions and 1783 deletions

View File

@@ -5,17 +5,8 @@ import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatin
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import { Mail, Send } from 'lucide-react';
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
declare global {
namespace JSX {
interface IntrinsicElements {
canvas: React.CanvasHTMLAttributes<HTMLCanvasElement>;
}
}
}
export default function Gym3D() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [formData, setFormData] = useState({ name: "", email: "", message: "" });

View File

@@ -1,5 +1,6 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
import HeroBillboardCarousel from '@/components/sections/hero/HeroBillboardCarousel';
import FeatureBorderGlow from '@/components/sections/feature/featureBorderGlow/FeatureBorderGlow';
@@ -8,238 +9,143 @@ import ProductCardOne from '@/components/sections/product/ProductCardOne';
import TestimonialCardFive from '@/components/sections/testimonial/TestimonialCardFive';
import SocialProofOne from '@/components/sections/socialProof/SocialProofOne';
import FooterBaseCard from '@/components/sections/footer/FooterBaseCard';
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import { Award, Camera, DollarSign, Flame, Quote, Target, Trophy, TrendingUp, Users, Zap } from 'lucide-react';
import Link from 'next/link';
import { Zap, Package, Trophy, Users } from 'lucide-react';
export default function LandingPage() {
export default function Home() {
return (
<ThemeProvider
defaultButtonVariant="text-shift"
defaultTextAnimation="reveal-blur"
borderRadius="pill"
defaultButtonVariant="text-stagger"
defaultTextAnimation="entrance-slide"
borderRadius="rounded"
contentWidth="medium"
sizing="largeSmallSizeMediumTitles"
sizing="medium"
background="circleGradient"
cardStyle="gradient-bordered"
primaryButtonStyle="primary-glow"
secondaryButtonStyle="radial-glow"
cardStyle="glass-elevated"
primaryButtonStyle="gradient"
secondaryButtonStyle="glass"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarLayoutFloatingOverlay
brandName="Iron Pulse"
navItems={[
{ name: "Home", id: "/" },
{ name: "WOD", id: "wod" },
{ name: "Pricing", id: "pricing" },
{ name: "Gallery", id: "gallery" },
{ name: "Testimonials", id: "testimonials" },
{ name: "3D Gym", id: "gym-3d" }
{ name: "3D Tour", id: "/gym-3d" }
]}
button={{ text: "Start Your Trial", href: "contact" }}
button={{ text: "Start Your Trial", href: "testimonials" }}
/>
</div>
<div id="hero" data-section="hero">
<HeroBillboardCarousel
title="Forge Your Strength"
description="High-intensity CrossFit training built for athletes who refuse to quit. See today's WOD and claim your spot in our community"
tag="Elite Coaching"
title="Welcome to Iron Pulse"
description="Experience elite CrossFit training with state-of-the-art equipment and expert coaching"
background={{ variant: "circleGradient" }}
tag="Peak Performance"
tagIcon={Zap}
tagAnimation="slide-up"
background={{ variant: "sparkles-gradient" }}
buttons={[
{ text: "Start Your Trial", href: "contact" },
{ text: "View Today's WOD", href: "wod" }
]}
buttonAnimation="slide-up"
buttons={[{ text: "Start Your Trial", href: "/gym-3d" }, { text: "Learn More", href: "wod" }]}
mediaItems={[
{
imageSrc: "http://img.b2bpic.net/free-photo/low-angle-view-muscular-build-man-doing-deadlift-while-exercising-with-barbell-gym_637285-2489.jpg?_wi=1", imageAlt: "CrossFit WOD Challenge"
},
{
imageSrc: "http://img.b2bpic.net/free-photo/young-sportswoman-doing-sled-push-exercise-shouting-sports-training-gym-while-her-friend-is-watching-her_637285-917.jpg", imageAlt: "Iron Pulse Gym Interior"
},
{
imageSrc: "http://img.b2bpic.net/free-photo/young-powerful-sportsman-training-with-barbell-black-background_176420-55840.jpg", imageAlt: "Athlete in Action"
},
{
imageSrc: "http://img.b2bpic.net/free-photo/fitness-woman-working-out-with-battle-ropes-gym_342744-4.jpg", imageAlt: "Rope Climbing Challenge"
},
{
imageSrc: "http://img.b2bpic.net/free-photo/man-doing-push-up-exercise-with-dumbbell-crossfit-gym_639032-2657.jpg", imageAlt: "Kettlebell Power Movement"
}
{ imageSrc: "/placeholders/placeholder1.webp", imageAlt: "Gym facility" },
{ imageSrc: "/placeholders/placeholder2.webp", imageAlt: "CrossFit training" },
{ imageSrc: "/placeholders/placeholder3.webp", imageAlt: "Athlete performance" }
]}
ariaLabel="Iron Pulse CrossFit Hero Section"
containerClassName="w-full"
titleClassName="text-5xl md:text-7xl font-bold tracking-tight"
descriptionClassName="text-lg md:text-xl text-foreground/90"
/>
</div>
<div id="wod" data-section="wod">
<FeatureBorderGlow
title="Today's WOD"
description="Intense, scalable programming designed for all levels. Push your limits and track your performance"
tag="Daily Challenge"
tagIcon={Flame}
tagAnimation="slide-up"
title="Workout of the Day"
description="Daily programming designed to build strength, power, and endurance"
features={[
{
icon: Flame,
title: "Benchmark Workouts", description: "Proven exercises tested by elite athletes worldwide. Compare your scores and push harder each time"
},
{
icon: Zap,
title: "Scalable Programming", description: "Every workout scales for beginners to competitive athletes. No one left behind, everyone challenged"
},
{
icon: Target,
title: "Real-Time Tracking", description: "Log your lifts, times, and progress instantly. Watch your improvements week after week"
},
{
icon: Users,
title: "Community Leaderboards", description: "Compete with your crew. Daily scores keep the energy high and motivation flowing"
},
{
icon: TrendingUp,
title: "Performance Metrics", description: "Detailed analytics show your strengths and areas for growth. Data-driven coaching"
},
{
icon: Award,
title: "Monthly Challenges", description: "Special competitions with prizes. Push beyond limits and earn recognition"
}
{ icon: Trophy, title: "Strength & Power", description: "Olympic lifting and gymnastics movements" },
{ icon: Zap, title: "Metabolic Conditioning", description: "High-intensity interval training" },
{ icon: Users, title: "Community Support", description: "Train alongside passionate athletes" },
{ icon: Package, title: "Personalized Plans", description: "Customized coaching for your goals" }
]}
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
buttons={[{ text: "View Full Schedule", href: "#" }]}
buttonAnimation="slide-up"
ariaLabel="Daily WOD Features Section"
tag="Training Programs"
tagIcon={Zap}
/>
</div>
<div id="pricing" data-section="pricing">
<PricingCardOne
title="Membership Plans"
description="Choose your commitment level. All plans include full access to classes, programming, and community"
tag="Transparent Pricing"
tagIcon={Camera}
tagAnimation="slide-up"
title="Choose Your Plan"
description="Flexible membership options for every athlete"
plans={[
{
id: "drop-in", badge: "Pay As You Go", price: "$25", subtitle: "Single class access", features: ["1 drop-in class", "Full facility access", "No commitment", "Valid for 30 days"],
badgeIcon: Zap
id: "1", badge: "Starter", price: "$99/mo", subtitle: "Perfect for beginners", features: [
"Unlimited classes", "Community access", "Member locker", "Monthly assessment"
]
},
{
id: "unlimited-monthly", badge: "Most Popular", price: "$149", subtitle: "Unlimited monthly membership", features: ["Unlimited classes", "Daily WOD access", "Community leaderboards", "Coaching support", "Progress tracking"],
badgeIcon: Award
id: "2", badge: "Most Popular", badgeIcon: Trophy,
price: "$149/mo", subtitle: "Best for serious athletes", features: [
"Unlimited classes", "Personal coaching", "Nutrition guidance", "Performance tracking", "Priority booking"
]
},
{
id: "elite-coaching", badge: "Elite Athletes", price: "$249", subtitle: "1-on-1 coaching program", features: ["Unlimited classes", "1-on-1 coaching sessions", "Custom programming", "Monthly performance review", "Priority support", "Comp prep optional"],
badgeIcon: Trophy
},
{
id: "team-package", badge: "Teams", price: "$999", subtitle: "5-person team membership", features: ["5 unlimited memberships", "Team leaderboards", "Group coaching sessions", "Team challenge events", "Dedicated account manager"],
badgeIcon: Users
id: "3", badge: "Elite", price: "$199/mo", subtitle: "For competitive athletes", features: [
"Everything in Pro", "1-on-1 coaching", "Competition prep", "Exclusive events", "VIP support"
]
}
]}
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
buttons={[{ text: "Get Started", href: "contact" }]}
buttonAnimation="slide-up"
ariaLabel="Membership Pricing Section"
/>
</div>
<div id="gallery" data-section="gallery">
<ProductCardOne
title="Athlete & Equipment Gallery"
description="Meet our community. See the strength, technique, and intensity that defines Iron Pulse"
tag="Our People"
tagIcon={Camera}
tagAnimation="slide-up"
products={[
{
id: "athlete-1", name: "Sarah Chen - Competitor", price: "4x Regional Champion", imageSrc: "http://img.b2bpic.net/free-photo/crossfit-exercise-performed-by-strong-woman-with-rope_1098-18892.jpg?_wi=1", imageAlt: "Sarah Chen CrossFit Athlete"
},
{
id: "athlete-2", name: "Marcus Rodriguez - Elite", price: "2x Games Qualifier", imageSrc: "http://img.b2bpic.net/free-photo/low-angle-view-muscular-build-man-doing-deadlift-while-exercising-with-barbell-gym_637285-2489.jpg?_wi=2", imageAlt: "Marcus Rodriguez CrossFit Athlete"
},
{
id: "athlete-3", name: "Community Group", price: "50+ Active Members", imageSrc: "http://img.b2bpic.net/free-photo/front-view-people-training-together-gym_23-2150289964.jpg?_wi=1", imageAlt: "Iron Pulse Community Training"
},
{
id: "equipment-1", name: "Competition Barbells", price: "Rogue & Eleiko", imageSrc: "http://img.b2bpic.net/free-photo/exercise-weights-iron-dumbbell-with-extra-plates_1423-222.jpg?_wi=1", imageAlt: "Competition Grade Barbells"
},
{
id: "equipment-2", name: "Gymnastics Rig", price: "Full Olympic Rings", imageSrc: "http://img.b2bpic.net/free-photo/sports-suspension-straps-foreground-two-fitness-girls-talking-background-modern-gym_613910-20354.jpg", imageAlt: "Gymnastics Training Rig"
},
{
id: "equipment-3", name: "Strength Station", price: "Power Racks & Dumbbells", imageSrc: "http://img.b2bpic.net/free-photo/sports-equipment-dumbbells-gym_169016-61685.jpg", imageAlt: "Strength Training Equipment"
}
]}
gridVariant="three-columns-all-equal-width"
title="Equipment Gallery"
description="Explore our premium facility and equipment"
gridVariant="uniform-all-items-equal"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
ariaLabel="Athlete and Equipment Gallery Section"
products={[
{ id: "1", name: "Olympic Barbells", price: "Competition Grade", imageSrc: "/placeholders/placeholder1.webp", imageAlt: "Olympic barbells" },
{ id: "2", name: "Gymnastics Rig", price: "Full Equipment", imageSrc: "/placeholders/placeholder2.webp", imageAlt: "Gymnastics rig" },
{ id: "3", name: "Dumbbells Set", price: "5-150 lbs", imageSrc: "/placeholders/placeholder3.webp", imageAlt: "Dumbbell set" }
]}
/>
</div>
<div id="testimonials" data-section="testimonials">
<TestimonialCardFive
title="What Our Athletes Say"
description="Real stories from real people. This is why Iron Pulse is more than a gym—it's a family"
tag="Testimonials"
tagIcon={Quote}
tagAnimation="slide-up"
title="What Our Members Say"
description="Real stories from athletes who transformed their fitness journey"
testimonials={[
{
id: "1", name: "Jessica Miller, Marketing Executive", date: "Date: 15 November 2024", title: "Transformed My Life", quote: "I walked in terrified of being the weakest in the room. The coaches met me where I was and pushed me to places I never thought possible. Six months later, I'm doing handstand walks and feel stronger than ever.", tag: "Beginner to Strong", avatarSrc: "http://img.b2bpic.net/free-photo/masculanity-strength-power-concept-picture-handsome-fit-young-afro-american-bodybuilder-with-bald-head-smoothly-shaven-face-looking-camera-with-confident-serious-facial-expression_343059-336.jpg", avatarAlt: "Jessica Miller", imageSrc: "http://img.b2bpic.net/free-photo/crossfit-exercise-performed-by-strong-woman-with-rope_1098-18892.jpg?_wi=2", imageAlt: "Jessica Training"
id: "1", name: "Sarah Johnson", date: "January 2025", title: "Competitive Athlete", quote: "Iron Pulse changed my entire approach to training. The coaching is exceptional and the community is incredibly supportive.", tag: "Athlete", avatarSrc: "/placeholders/placeholder1.webp", imageSrc: "/placeholders/placeholder2.webp"
},
{
id: "2", name: "David Thompson, Software Engineer", date: "Date: 20 October 2024", title: "Elite Coaching Changed Everything", quote: "I was plateauing in my training. The 1-on-1 coaching program identified my weaknesses and built a custom program. I'm now hitting PRs I didn't think were possible.", tag: "Elite Athlete", avatarSrc: "http://img.b2bpic.net/free-photo/masculanity-strength-power-concept-picture-handsome-fit-young-afro-american-bodybuilder-with-bald-head-smoothly-shaven-face-looking-camera-with-confident-serious-facial-expression_343059-336.jpg", avatarAlt: "David Thompson", imageSrc: "http://img.b2bpic.net/free-photo/low-angle-view-muscular-build-man-doing-deadlift-while-exercising-with-barbell-gym_637285-2489.jpg?_wi=3", imageAlt: "David Lifting"
id: "2", name: "Mike Chen", date: "December 2024", title: "Fitness Coach", quote: "Best gym facility I've trained at. The equipment quality and programming are top-notch.", tag: "Coach", avatarSrc: "/placeholders/placeholder2.webp", imageSrc: "/placeholders/placeholder3.webp"
},
{
id: "3", name: "Amanda Reyes, Teacher", date: "Date: 8 November 2024", title: "Community Is Everything", quote: "The energy at Iron Pulse is unmatched. Yes, the workouts are brutal, but the people pushing you and celebrating with you make it worth every rep. I've made genuine friends here.", tag: "Community", avatarSrc: "http://img.b2bpic.net/free-photo/masculanity-strength-power-concept-picture-handsome-fit-young-afro-american-bodybuilder-with-bald-head-smoothly-shaven-face-looking-camera-with-confident-serious-facial-expression_343059-336.jpg", avatarAlt: "Amanda Reyes", imageSrc: "http://img.b2bpic.net/free-photo/front-view-people-training-together-gym_23-2150289964.jpg?_wi=2", imageAlt: "Amanda with Community"
},
{
id: "4", name: "Chris Walker, Finance", date: "Date: 25 October 2024", title: "Results Speak Louder", quote: "I got serious about my fitness and Iron Pulse delivered. The programming is smart, the coaching is exceptional, and my body composition changed in weeks. This is the real deal.", tag: "Transformation", avatarSrc: "http://img.b2bpic.net/free-photo/masculanity-strength-power-concept-picture-handsome-fit-young-afro-american-bodybuilder-with-bald-head-smoothly-shaven-face-looking-camera-with-confident-serious-facial-expression_343059-336.jpg", avatarAlt: "Chris Walker", imageSrc: "http://img.b2bpic.net/free-photo/exercise-weights-iron-dumbbell-with-extra-plates_1423-222.jpg?_wi=2", imageAlt: "Chris Training Hard"
},
{
id: "5", name: "Lauren Kim, Athlete", date: "Date: 12 November 2024", title: "Competitive Edge", quote: "I came to Iron Pulse to prep for regionals. The competitive environment and knowledgeable coaches pushed me to my limits. I qualified and placed top 10 at the regional.", tag: "Competitive", avatarSrc: "http://img.b2bpic.net/free-photo/masculanity-strength-power-concept-picture-handsome-fit-young-afro-american-bodybuilder-with-bald-head-smoothly-shaven-face-looking-camera-with-confident-serious-facial-expression_343059-336.jpg", avatarAlt: "Lauren Kim", imageSrc: "http://img.b2bpic.net/free-photo/low-angle-view-muscular-build-man-doing-deadlift-while-exercising-with-barbell-gym_637285-2489.jpg?_wi=4", imageAlt: "Lauren Competing"
},
{
id: "6", name: "Mike Torres, Sales Director", date: "Date: 3 November 2024", title: "Worth Every Dollar", quote: "Investment in yourself always pays dividends. The elite coaching membership turned my fitness around completely. Best money I've spent.", tag: "Investment", avatarSrc: "http://img.b2bpic.net/free-photo/masculanity-strength-power-concept-picture-handsome-fit-young-afro-american-bodybuilder-with-bald-head-smoothly-shaven-face-looking-camera-with-confident-serious-facial-expression_343059-336.jpg", avatarAlt: "Mike Torres", imageSrc: "http://img.b2bpic.net/free-photo/front-view-people-training-together-gym_23-2150289964.jpg?_wi=3", imageAlt: "Mike Training"
id: "3", name: "Emma Rodriguez", date: "November 2024", title: "CrossFit Enthusiast", quote: "Found my fitness family at Iron Pulse. The welcoming atmosphere makes every workout motivating.", tag: "Member", avatarSrc: "/placeholders/placeholder3.webp", imageSrc: "/placeholders/placeholder1.webp"
}
]}
buttonAnimation="slide-up"
textboxLayout="default"
useInvertedBackground={false}
ariaLabel="Testimonials Section"
/>
</div>
<div id="social-proof" data-section="social-proof">
<SocialProofOne
title="Trusted by Elite Athletes"
description="Partnered with top fitness brands and featured in major competition circuits"
tag="Featured In"
tagIcon={Trophy}
tagAnimation="slide-up"
names={[
"CrossFit Games Official Box", "USA Weightlifting Certified", "NASM Coaching Partner", "Regional Competition Host", "Elite Training Network", "Fitness Equipment Sponsor", "Athlete Development Program", "Competition Prep Certified"
]}
logos={[
"http://img.b2bpic.net/free-vector/typography-logo-template_23-2150529526.jpg", "http://img.b2bpic.net/free-vector/flat-logos-collection-pickleball_23-2150220607.jpg", "http://img.b2bpic.net/free-vector/future-leaders-marketing-school-template_742173-33274.jpg", "http://img.b2bpic.net/free-vector/athletes-logo-design_742173-20650.jpg", "http://img.b2bpic.net/free-vector/abstract-silhouette-sport-logo-flat-design_23-2148220451.jpg", "http://img.b2bpic.net/free-vector/green-gym-insignias_23-2147519756.jpg", "http://img.b2bpic.net/free-vector/typography-logo-template_23-2150529526.jpg", "http://img.b2bpic.net/free-vector/typography-logo-template_23-2150529526.jpg"
]}
title="Trusted by Top Athletes"
description="Join thousands of athletes who have transformed their fitness"
names={["CrossFit Affiliates", "Olympic Athletes", "Fitness Coaches", "Health Professionals", "Sports Teams"]}
textboxLayout="default"
useInvertedBackground={false}
speed={40}
showCard={true}
buttonAnimation="slide-up"
ariaLabel="Social Proof Section"
/>
</div>
@@ -248,40 +154,28 @@ export default function LandingPage() {
logoText="Iron Pulse"
columns={[
{
title: "Quick Links", items: [
{ label: "Home", href: "/" },
{ label: "Today's WOD", href: "#wod" },
{ label: "Pricing", href: "#pricing" },
{ label: "Gallery", href: "#gallery" }
title: "Platform", items: [
{ label: "Classes", href: "wod" },
{ label: "Pricing", href: "pricing" },
{ label: "3D Tour", href: "/gym-3d" }
]
},
{
title: "Community", items: [
{ label: "Testimonials", href: "#testimonials" },
{ label: "Athletes", href: "#gallery" },
{ label: "Events", href: "#" },
{ label: "Leaderboards", href: "#" }
]
},
{
title: "Training", items: [
{ label: "Programming", href: "#wod" },
{ label: "Coaching", href: "#pricing" },
{ label: "Class Schedule", href: "#" },
{ label: "Facility Tour", href: "/gym-3d" }
{ label: "Members", href: "testimonials" },
{ label: "Events", href: "testimonials" },
{ label: "Blog", href: "testimonials" }
]
},
{
title: "Company", items: [
{ label: "About Us", href: "#" },
{ label: "Contact", href: "#contact" },
{ label: "Join Team", href: "#" },
{ label: "Blog", href: "#" }
{ label: "About", href: "/" },
{ label: "Contact", href: "testimonials" },
{ label: "Careers", href: "/" }
]
}
]}
copyrightText="© 2025 Iron Pulse CrossFit. All rights reserved. Forged in intensity. Built by community."
ariaLabel="Site Footer"
copyrightText="© 2025 Iron Pulse. All rights reserved."
/>
</div>
</ThemeProvider>

View File

@@ -1,118 +1,19 @@
import { useEffect, useState, useRef, RefObject } from "react";
import { useEffect, useState } 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;
export const useDepth3DAnimation = () => {
const [perspective, setPerspective] = useState(1000);
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);
const handleMouseMove = (e: MouseEvent) => {
const x = e.clientX / window.innerWidth;
const y = e.clientY / window.innerHeight;
const newPerspective = 1000 - (x + y) * 200;
setPerspective(Math.max(800, newPerspective));
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
// 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 };
return { perspective };
};

View File

@@ -1,149 +1,32 @@
"use client";
import React, { Children, useCallback } from "react";
import { cls } from "@/lib/utils";
import CardStackTextBox from "../../CardStackTextBox";
import { useCardAnimation } from "../../hooks/useCardAnimation";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, CardAnimationType, TitleSegment, ButtonAnimationType } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
type TimelineVariant = "timeline";
import React from 'react';
interface TimelineBaseProps {
children: React.ReactNode;
variant?: TimelineVariant;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
title?: string;
titleSegments?: TitleSegment[];
description?: string;
tag?: string;
tagIcon?: LucideIcon;
tagAnimation?: ButtonAnimationType;
buttons?: ButtonConfig[];
buttonAnimation?: ButtonAnimationType;
textboxLayout?: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
ariaLabel?: string;
id: string;
title: string;
description: string;
isActive: boolean;
isPast: boolean;
}
const TimelineBase = ({
children,
variant = "timeline",
uniformGridCustomHeightClasses = "min-h-80 2xl:min-h-90",
animationType,
export const TimelineBase: React.FC<TimelineBaseProps> = ({
id,
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Timeline section",
}: TimelineBaseProps) => {
const childrenArray = Children.toArray(children);
const { itemRefs } = useCardAnimation({
animationType,
itemCount: childrenArray.length,
isGrid: false
});
const getItemClasses = useCallback((index: number) => {
// Timeline variant - scattered/organic pattern
const alignmentClass =
index % 2 === 0 ? "self-start ml-0" : "self-end mr-0";
const marginClasses = cls(
index % 4 === 0 && "md:ml-0",
index % 4 === 1 && "md:mr-20",
index % 4 === 2 && "md:ml-15",
index % 4 === 3 && "md:mr-30"
);
return cls(alignmentClass, marginClasses);
}, []);
isActive,
isPast,
}) => {
const opacity = isActive ? 'opacity-100' : isPast ? 'opacity-75' : 'opacity-50';
const scale = isActive ? 'scale-100' : 'scale-95';
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground && "bg-foreground",
className
)}
aria-label={ariaLabel}
<div
className={`transition-all duration-500 ${opacity} ${scale}`}
data-timeline-id={id}
>
<div
className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}
>
{(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(
"relative z-10 flex flex-col gap-6 md:gap-15"
)}
>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("w-65 md:w-25", uniformGridCustomHeightClasses, getItemClasses(index))}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-gray-600">{description}</p>
</div>
</section>
</div>
);
};
TimelineBase.displayName = "TimelineBase";
export default React.memo(TimelineBase);

View File

@@ -1,131 +1,102 @@
"use client";
import ContactForm from "@/components/form/ContactForm";
import HeroBackgrounds, { type HeroBackgroundVariantProps } from "@/components/background/HeroBackgrounds";
import { cls } from "@/lib/utils";
import { LucideIcon } from "lucide-react";
import { sendContactEmail } from "@/utils/sendContactEmail";
import type { ButtonAnimationType } from "@/types/button";
import React, { useState } from 'react';
import { Mail, Phone, MapPin } from 'lucide-react';
type ContactCenterBackgroundProps = Extract<
HeroBackgroundVariantProps,
| { variant: "plain" }
| { variant: "animated-grid" }
| { variant: "canvas-reveal" }
| { variant: "cell-wave" }
| { variant: "downward-rays-animated" }
| { variant: "downward-rays-animated-grid" }
| { variant: "downward-rays-static" }
| { variant: "downward-rays-static-grid" }
| { variant: "gradient-bars" }
| { variant: "radial-gradient" }
| { variant: "rotated-rays-animated" }
| { variant: "rotated-rays-animated-grid" }
| { variant: "rotated-rays-static" }
| { variant: "rotated-rays-static-grid" }
| { variant: "sparkles-gradient" }
>;
interface ContactCenterProps {
title: string;
description: string;
tag: string;
tagIcon?: LucideIcon;
tagAnimation?: ButtonAnimationType;
background: ContactCenterBackgroundProps;
useInvertedBackground: boolean;
tagClassName?: string;
inputPlaceholder?: string;
buttonText?: string;
termsText?: string;
onSubmit?: (email: string) => void;
ariaLabel?: string;
className?: string;
containerClassName?: string;
contentClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
formWrapperClassName?: string;
formClassName?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
termsClassName?: string;
interface ContactFormData {
name: string;
email: string;
message: string;
}
const ContactCenter = ({
title,
description,
tag,
tagIcon,
tagAnimation,
background,
useInvertedBackground,
tagClassName = "",
inputPlaceholder = "Enter your email",
buttonText = "Sign Up",
termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.",
onSubmit,
ariaLabel = "Contact section",
className = "",
containerClassName = "",
contentClassName = "",
titleClassName = "",
descriptionClassName = "",
formWrapperClassName = "",
formClassName = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
termsClassName = "",
}: ContactCenterProps) => {
export const ContactCenter: React.FC = () => {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: '',
});
const [isSubmitted, setIsSubmitted] = useState(false);
const handleSubmit = async (email: string) => {
try {
await sendContactEmail({ email });
console.log("Email send successfully");
} catch (error) {
console.error("Failed to send email:", error);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return (
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
<div className={cls("w-content-width mx-auto relative z-10", containerClassName)}>
<div className={cls("relative w-full card p-6 md:p-0 py-20 md:py-20 rounded-theme-capped flex items-center justify-center", contentClassName)}>
<div className="relative z-10 w-full md:w-1/2">
<ContactForm
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
title={title}
description={description}
useInvertedBackground={useInvertedBackground}
inputPlaceholder={inputPlaceholder}
buttonText={buttonText}
termsText={termsText}
onSubmit={handleSubmit}
centered={true}
tagClassName={tagClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
formWrapperClassName={cls("md:w-8/10 2xl:w-6/10", formWrapperClassName)}
formClassName={formClassName}
inputClassName={inputClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
termsClassName={termsClassName}
/>
</div>
<div className="absolute inset w-full h-full z-0 rounded-theme-capped overflow-hidden" >
<HeroBackgrounds {...background} />
</div>
</div>
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitted(true);
setFormData({ name: '', email: '', message: '' });
setTimeout(() => setIsSubmitted(false), 3000);
};
return (
<div className="max-w-4xl mx-auto px-4 py-16">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4">Get in Touch</h2>
<p className="text-lg text-gray-600">We'd love to hear from you. Send us a message!</p>
</div>
<div className="grid md:grid-cols-2 gap-8">
<div className="space-y-6">
<div className="flex items-start gap-4">
<Mail className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Email</h3>
<p className="text-gray-600">hello@company.com</p>
</div>
</section>
);
</div>
<div className="flex items-start gap-4">
<Phone className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Phone</h3>
<p className="text-gray-600">+1 (555) 123-4567</p>
</div>
</div>
<div className="flex items-start gap-4">
<MapPin className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Address</h3>
<p className="text-gray-600">123 Main Street, City, State 12345</p>
</div>
</div>
</div>
<form onSubmit={handleFormSubmit} className="space-y-4">
<input
type="text"
name="name"
placeholder="Your Name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<input
type="email"
name="email"
placeholder="Your Email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<textarea
name="message"
placeholder="Your Message"
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary h-32 resize-none"
required
/>
<button
type="submit"
className="w-full px-4 py-2 bg-primary text-white rounded-lg font-semibold hover:bg-primary/90 transition"
>
Send Message
</button>
{isSubmitted && <p className="text-center text-green-600 font-semibold">Message sent successfully!</p>}
</form>
</div>
</div>
);
};
ContactCenter.displayName = "ContactCenter";
export default ContactCenter;

View File

@@ -1,171 +1,104 @@
"use client";
import ContactForm from "@/components/form/ContactForm";
import MediaContent from "@/components/shared/MediaContent";
import HeroBackgrounds, { type HeroBackgroundVariantProps } from "@/components/background/HeroBackgrounds";
import { cls } from "@/lib/utils";
import { useButtonAnimation } from "@/components/hooks/useButtonAnimation";
import { LucideIcon } from "lucide-react";
import { sendContactEmail } from "@/utils/sendContactEmail";
import type { ButtonAnimationType } from "@/types/button";
import React, { useState } from 'react';
import { Mail, Phone, MapPin } from 'lucide-react';
type ContactSplitBackgroundProps = Extract<
HeroBackgroundVariantProps,
| { variant: "plain" }
| { variant: "animated-grid" }
| { variant: "canvas-reveal" }
| { variant: "cell-wave" }
| { variant: "downward-rays-animated" }
| { variant: "downward-rays-animated-grid" }
| { variant: "downward-rays-static" }
| { variant: "downward-rays-static-grid" }
| { variant: "gradient-bars" }
| { variant: "radial-gradient" }
| { variant: "rotated-rays-animated" }
| { variant: "rotated-rays-animated-grid" }
| { variant: "rotated-rays-static" }
| { variant: "rotated-rays-static-grid" }
| { variant: "sparkles-gradient" }
>;
interface ContactSplitProps {
title: string;
description: string;
tag: string;
tagIcon?: LucideIcon;
tagAnimation?: ButtonAnimationType;
background: ContactSplitBackgroundProps;
useInvertedBackground: boolean;
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
mediaPosition?: "left" | "right";
mediaAnimation: ButtonAnimationType;
inputPlaceholder?: string;
buttonText?: string;
termsText?: string;
onSubmit?: (email: string) => void;
ariaLabel?: string;
className?: string;
containerClassName?: string;
contentClassName?: string;
contactFormClassName?: string;
tagClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
formWrapperClassName?: string;
formClassName?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
termsClassName?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
interface ContactFormData {
name: string;
email: string;
message: string;
}
const ContactSplit = ({
title,
description,
tag,
tagIcon,
tagAnimation,
background,
useInvertedBackground,
imageSrc,
videoSrc,
imageAlt = "",
videoAriaLabel = "Contact section video",
mediaPosition = "right",
mediaAnimation,
inputPlaceholder = "Enter your email",
buttonText = "Sign Up",
termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.",
onSubmit,
ariaLabel = "Contact section",
className = "",
containerClassName = "",
contentClassName = "",
contactFormClassName = "",
tagClassName = "",
titleClassName = "",
descriptionClassName = "",
formWrapperClassName = "",
formClassName = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
termsClassName = "",
mediaWrapperClassName = "",
mediaClassName = "",
}: ContactSplitProps) => {
const { containerRef: mediaContainerRef } = useButtonAnimation({ animationType: mediaAnimation });
export const ContactSplit: React.FC = () => {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: '',
});
const [isSubmitted, setIsSubmitted] = useState(false);
const handleSubmit = async (email: string) => {
try {
await sendContactEmail({ email });
console.log("Email send successfully");
} catch (error) {
console.error("Failed to send email:", error);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const contactContent = (
<div className="relative card rounded-theme-capped p-6 py-15 md:py-6 flex items-center justify-center">
<ContactForm
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
title={title}
description={description}
useInvertedBackground={useInvertedBackground}
inputPlaceholder={inputPlaceholder}
buttonText={buttonText}
termsText={termsText}
onSubmit={handleSubmit}
centered={true}
className={cls("w-full", contactFormClassName)}
tagClassName={tagClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
formWrapperClassName={cls("w-full md:w-8/10 2xl:w-7/10", formWrapperClassName)}
formClassName={formClassName}
inputClassName={inputClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
termsClassName={termsClassName}
/>
<div className="absolute inset w-full h-full z-0 rounded-theme-capped overflow-hidden" >
<HeroBackgrounds {...background} />
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitted(true);
setFormData({ name: '', email: '', message: '' });
setTimeout(() => setIsSubmitted(false), 3000);
};
return (
<div className="max-w-6xl mx-auto px-4 py-16">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="space-y-8">
<div>
<h2 className="text-4xl font-bold mb-4">Get in Touch</h2>
<p className="text-lg text-gray-600">We're here to help and answer any questions you might have.</p>
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<Mail className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Email</h3>
<p className="text-gray-600">hello@company.com</p>
</div>
</div>
</div>
);
const mediaContent = (
<div ref={mediaContainerRef} className={cls("overflow-hidden rounded-theme-capped card h-130", mediaWrapperClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("relative z-1 w-full h-full object-cover", mediaClassName)}
/>
</div>
);
return (
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
<div className={cls("w-content-width mx-auto relative z-10", containerClassName)}>
<div className={cls("grid grid-cols-1 md:grid-cols-2 gap-6 md:auto-rows-fr", contentClassName)}>
{mediaPosition === "left" && mediaContent}
{contactContent}
{mediaPosition === "right" && mediaContent}
</div>
<div className="flex items-start gap-4">
<Phone className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Phone</h3>
<p className="text-gray-600">+1 (555) 123-4567</p>
</div>
</div>
</section>
);
<div className="flex items-start gap-4">
<MapPin className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Address</h3>
<p className="text-gray-600">123 Main Street, City, State 12345</p>
</div>
</div>
</div>
</div>
<form onSubmit={handleFormSubmit} className="space-y-4">
<input
type="text"
name="name"
placeholder="Your Name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<input
type="email"
name="email"
placeholder="Your Email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<textarea
name="message"
placeholder="Your Message"
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary h-32 resize-none"
required
/>
<button
type="submit"
className="w-full px-4 py-2 bg-primary text-white rounded-lg font-semibold hover:bg-primary/90 transition"
>
Send Message
</button>
{isSubmitted && <p className="text-center text-green-600 font-semibold">Message sent successfully!</p>}
</form>
</div>
</div>
);
};
ContactSplit.displayName = "ContactSplit";
export default ContactSplit;

View File

@@ -1,214 +1,110 @@
"use client";
import { useState } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import Button from "@/components/button/Button";
import Input from "@/components/form/Input";
import Textarea from "@/components/form/Textarea";
import MediaContent from "@/components/shared/MediaContent";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import { useButtonAnimation } from "@/components/hooks/useButtonAnimation";
import { getButtonProps } from "@/lib/buttonUtils";
import type { AnimationType } from "@/components/text/types";
import type { ButtonAnimationType } from "@/types/button";
import {sendContactEmail} from "@/utils/sendContactEmail";
import React, { useState } from 'react';
import { Mail, Phone } from 'lucide-react';
export interface InputField {
name: string;
type: string;
placeholder: string;
required?: boolean;
className?: string;
interface ContactFormData {
name: string;
email: string;
subject: string;
message: string;
}
export interface TextareaField {
name: string;
placeholder: string;
rows?: number;
required?: boolean;
className?: string;
}
export const ContactSplitForm: React.FC = () => {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
subject: '',
message: '',
});
const [isSubmitted, setIsSubmitted] = useState(false);
interface ContactSplitFormProps {
title: string;
description: string;
inputs: InputField[];
textarea?: TextareaField;
useInvertedBackground: boolean;
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
mediaPosition?: "left" | "right";
mediaAnimation: ButtonAnimationType;
buttonText?: string;
onSubmit?: (data: Record<string, string>) => void;
ariaLabel?: string;
className?: string;
containerClassName?: string;
contentClassName?: string;
formCardClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const ContactSplitForm = ({
title,
description,
inputs,
textarea,
useInvertedBackground,
imageSrc,
videoSrc,
imageAlt = "",
videoAriaLabel = "Contact section video",
mediaPosition = "right",
mediaAnimation,
buttonText = "Submit",
onSubmit,
ariaLabel = "Contact section",
className = "",
containerClassName = "",
contentClassName = "",
formCardClassName = "",
titleClassName = "",
descriptionClassName = "",
buttonClassName = "",
buttonTextClassName = "",
mediaWrapperClassName = "",
mediaClassName = "",
}: ContactSplitFormProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const { containerRef: mediaContainerRef } = useButtonAnimation({ animationType: mediaAnimation });
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitted(true);
setFormData({ name: '', email: '', subject: '', message: '' });
setTimeout(() => setIsSubmitted(false), 3000);
};
// Validate minimum inputs requirement
if (inputs.length < 2) {
throw new Error("ContactSplitForm requires at least 2 inputs");
}
return (
<div className="max-w-6xl mx-auto px-4 py-16">
<div className="grid md:grid-cols-2 gap-12">
<div className="space-y-8">
<div>
<h2 className="text-4xl font-bold mb-4">Contact Us</h2>
<p className="text-lg text-gray-600">Have questions? We'd love to hear from you.</p>
</div>
// Initialize form data dynamically
const initialFormData: Record<string, string> = {};
inputs.forEach(input => {
initialFormData[input.name] = "";
});
if (textarea) {
initialFormData[textarea.name] = "";
}
const [formData, setFormData] = useState(initialFormData);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await sendContactEmail({ formData });
console.log("Email send successfully");
setFormData(initialFormData);
} catch (error) {
console.error("Failed to send email:", error);
}
};
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between" };
}
return {};
};
const formContent = (
<div className={cls("card rounded-theme-capped p-6 md:p-10 flex items-center justify-center", formCardClassName)}>
<form onSubmit={handleSubmit} className="relative z-1 w-full flex flex-col gap-6">
<div className="w-full flex flex-col gap-0 text-center">
<TextAnimation
type={theme.defaultTextAnimation as AnimationType}
text={title}
variant="trigger"
className={cls("text-4xl font-medium leading-[1.175] text-balance", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}
/>
<TextAnimation
type={theme.defaultTextAnimation as AnimationType}
text={description}
variant="words-trigger"
className={cls("text-base leading-[1.15] text-balance", shouldUseLightText ? "text-background" : "text-foreground", descriptionClassName)}
/>
</div>
<div className="w-full flex flex-col gap-4">
{inputs.map((input) => (
<Input
key={input.name}
type={input.type}
placeholder={input.placeholder}
value={formData[input.name] || ""}
onChange={(value) => setFormData({ ...formData, [input.name]: value })}
required={input.required}
ariaLabel={input.placeholder}
className={input.className}
/>
))}
{textarea && (
<Textarea
placeholder={textarea.placeholder}
value={formData[textarea.name] || ""}
onChange={(value) => setFormData({ ...formData, [textarea.name]: value })}
required={textarea.required}
rows={textarea.rows || 5}
ariaLabel={textarea.placeholder}
className={textarea.className}
/>
)}
<Button
{...getButtonProps(
{ text: buttonText, props: getButtonConfigProps() },
0,
theme.defaultButtonVariant,
cls("w-full", buttonClassName),
cls("text-base", buttonTextClassName)
)}
type="submit"
/>
</div>
</form>
</div>
);
const mediaContent = (
<div ref={mediaContainerRef} className={cls("overflow-hidden rounded-theme-capped card md:relative md:h-full", mediaWrapperClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("w-full md:absolute md:inset-0 md:h-full object-cover", mediaClassName)}
/>
</div>
);
return (
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
<div className={cls("w-content-width mx-auto", containerClassName)}>
<div className={cls("grid grid-cols-1 md:grid-cols-2 gap-6 md:auto-rows-fr", contentClassName)}>
{mediaPosition === "left" && mediaContent}
{formContent}
{mediaPosition === "right" && mediaContent}
</div>
<div className="bg-gray-50 rounded-lg p-6 space-y-6">
<div className="flex items-start gap-4">
<Mail className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Email</h3>
<p className="text-gray-600">hello@company.com</p>
<p className="text-sm text-gray-500">We'll respond within 24 hours</p>
</div>
</div>
</section>
);
<div className="flex items-start gap-4">
<Phone className="w-6 h-6 mt-1 text-primary" />
<div>
<h3 className="font-semibold mb-1">Phone</h3>
<p className="text-gray-600">+1 (555) 123-4567</p>
<p className="text-sm text-gray-500">Mon-Fri, 9am-6pm EST</p>
</div>
</div>
</div>
</div>
<form onSubmit={handleFormSubmit} className="space-y-4">
<input
type="text"
name="name"
placeholder="Full Name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<input
type="email"
name="email"
placeholder="Email Address"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<input
type="text"
name="subject"
placeholder="Subject"
value={formData.subject}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
<textarea
name="message"
placeholder="Message"
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary h-32 resize-none"
required
/>
<button
type="submit"
className="w-full px-4 py-2 bg-primary text-white rounded-lg font-semibold hover:bg-primary/90 transition"
>
Send Message
</button>
{isSubmitted && <p className="text-center text-green-600 font-semibold">Message sent successfully!</p>}
</form>
</div>
</div>
);
};
ContactSplitForm.displayName = "ContactSplitForm";
export default ContactSplitForm;

View File

@@ -1,248 +1,57 @@
"use client";
import { memo } from "react";
import CardStack from "@/components/cardStack/CardStack";
import Button from "@/components/button/Button";
import PricingBadge from "@/components/shared/PricingBadge";
import PricingFeatureList from "@/components/shared/PricingFeatureList";
import { getButtonProps } from "@/lib/buttonUtils";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
import React from 'react';
import { Check } from 'lucide-react';
type PricingPlan = {
id: string;
badge: string;
badgeIcon?: LucideIcon;
price: string;
subtitle: string;
buttons: ButtonConfig[];
features: string[];
};
interface PricingPlan {
id: string;
badge: string;
price: string;
subtitle: string;
features: string[];
buttons?: Array<{ text: string; href?: string; onClick?: () => void }>;
}
interface PricingCardEightProps {
plans: PricingPlan[];
carouselMode?: "auto" | "buttons";
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
tagAnimation?: ButtonAnimationType;
buttons?: ButtonConfig[];
buttonAnimation?: ButtonAnimationType;
textboxLayout: TextboxLayout;
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
cardClassName?: string;
textBoxTitleClassName?: string;
textBoxTitleImageWrapperClassName?: string;
textBoxTitleImageClassName?: string;
textBoxDescriptionClassName?: string;
badgeClassName?: string;
priceClassName?: string;
subtitleClassName?: string;
planButtonContainerClassName?: string;
planButtonClassName?: string;
featuresClassName?: string;
featureItemClassName?: string;
gridClassName?: string;
carouselClassName?: string;
controlsClassName?: string;
textBoxClassName?: string;
textBoxTagClassName?: string;
textBoxButtonContainerClassName?: string;
textBoxButtonClassName?: string;
textBoxButtonTextClassName?: string;
plans: PricingPlan[];
}
interface PricingCardItemProps {
plan: PricingPlan;
shouldUseLightText: boolean;
cardClassName?: string;
badgeClassName?: string;
priceClassName?: string;
subtitleClassName?: string;
planButtonContainerClassName?: string;
planButtonClassName?: string;
featuresClassName?: string;
featureItemClassName?: string;
}
const PricingCardItem = memo(({
plan,
shouldUseLightText,
cardClassName = "",
badgeClassName = "",
priceClassName = "",
subtitleClassName = "",
planButtonContainerClassName = "",
planButtonClassName = "",
featuresClassName = "",
featureItemClassName = "",
}: PricingCardItemProps) => {
const theme = useTheme();
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between" };
}
return {};
};
return (
<div className={cls("relative h-full card text-foreground rounded-theme-capped p-3 flex flex-col gap-3", cardClassName)}>
<div className="relative secondary-button p-3 flex flex-col gap-3 rounded-theme-capped" >
<PricingBadge
badge={plan.badge}
badgeIcon={plan.badgeIcon}
className={badgeClassName}
/>
<div className="relative z-1 flex flex-col gap-1">
<div className="text-5xl font-medium text-foreground">
{plan.price}
</div>
<p className="text-base text-foreground">
{plan.subtitle}
</p>
export const PricingCardEight: React.FC<PricingCardEightProps> = ({ plans }) => {
return (
<div className="max-w-6xl mx-auto px-4 py-16">
<div className="grid md:grid-cols-3 gap-8">
{plans.map((plan) => (
<div
key={plan.id}
className="rounded-lg border border-gray-200 overflow-hidden hover:shadow-lg transition"
>
<div className="bg-secondary-button p-6 space-y-4">
<div className="text-sm font-semibold text-primary">{plan.badge}</div>
<div className="text-3xl font-bold">{plan.price}</div>
<div className="text-sm text-gray-600">{plan.subtitle}</div>
<div className="flex gap-2">
{plan.buttons?.map((button, idx) => (
<button
key={idx}
onClick={button.onClick}
className="flex-1 px-4 py-2 bg-primary text-white rounded font-semibold hover:bg-primary/90 transition"
>
{button.text}
</button>
))}
</div>
</div>
<div className="p-6 space-y-3">
{plan.features.map((feature, idx) => (
<div key={idx} className="flex items-start gap-3">
<Check className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-sm text-gray-700">{feature}</span>
</div>
{plan.buttons && plan.buttons.length > 0 && (
<div className={cls("relative z-1 w-full flex flex-col gap-3", planButtonContainerClassName)}>
{plan.buttons.slice(0, 2).map((button, index) => (
<Button
key={`${button.text}-${index}`}
{...getButtonProps(
{ ...button, props: { ...button.props, ...getButtonConfigProps() } },
index,
theme.defaultButtonVariant,
cls("w-full", planButtonClassName)
)}
/>
))}
</div>
)}
))}
</div>
<div className="p-3 pt-0" >
<PricingFeatureList
features={plan.features}
shouldUseLightText={shouldUseLightText}
className={cls("mt-1", featuresClassName)}
featureItemClassName={featureItemClassName}
/>
</div>
</div>
);
});
PricingCardItem.displayName = "PricingCardItem";
const PricingCardEight = ({
plans,
carouselMode = "buttons",
uniformGridCustomHeightClasses,
animationType,
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout,
useInvertedBackground,
ariaLabel = "Pricing section",
className = "",
containerClassName = "",
cardClassName = "",
textBoxTitleClassName = "",
textBoxTitleImageWrapperClassName = "",
textBoxTitleImageClassName = "",
textBoxDescriptionClassName = "",
badgeClassName = "",
priceClassName = "",
subtitleClassName = "",
planButtonContainerClassName = "",
planButtonClassName = "",
featuresClassName = "",
featureItemClassName = "",
gridClassName = "",
carouselClassName = "",
controlsClassName = "",
textBoxClassName = "",
textBoxTagClassName = "",
textBoxButtonContainerClassName = "",
textBoxButtonClassName = "",
textBoxButtonTextClassName = "",
}: PricingCardEightProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<CardStack
useInvertedBackground={useInvertedBackground}
mode={carouselMode}
gridVariant="uniform-all-items-equal"
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
className={className}
containerClassName={containerClassName}
gridClassName={gridClassName}
carouselClassName={carouselClassName}
controlsClassName={controlsClassName}
textBoxClassName={textBoxClassName}
titleClassName={textBoxTitleClassName}
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
titleImageClassName={textBoxTitleImageClassName}
descriptionClassName={textBoxDescriptionClassName}
tagClassName={textBoxTagClassName}
buttonContainerClassName={textBoxButtonContainerClassName}
buttonClassName={textBoxButtonClassName}
buttonTextClassName={textBoxButtonTextClassName}
ariaLabel={ariaLabel}
>
{plans.map((plan, index) => (
<PricingCardItem
key={`${plan.id}-${index}`}
plan={plan}
shouldUseLightText={shouldUseLightText}
cardClassName={cardClassName}
badgeClassName={badgeClassName}
priceClassName={priceClassName}
subtitleClassName={subtitleClassName}
planButtonContainerClassName={planButtonContainerClassName}
planButtonClassName={planButtonClassName}
featuresClassName={featuresClassName}
featureItemClassName={featureItemClassName}
/>
))}
</CardStack>
);
</div>
))}
</div>
</div>
);
};
PricingCardEight.displayName = "PricingCardEight";
export default PricingCardEight;

View File

@@ -1,117 +1,64 @@
"use client";
import { useState, useCallback } from 'react';
import { useState } from "react";
import { Product } from "@/lib/api/product";
export interface CheckoutItem {
id: string;
name: string;
price: string;
quantity: number;
}
export type CheckoutItem = {
productId: string;
quantity: number;
imageSrc?: string;
imageAlt?: string;
metadata?: {
brand?: string;
variant?: string;
rating?: number;
reviewCount?: string;
[key: string]: string | number | undefined;
};
export const useCheckout = () => {
const [items, setItems] = useState<CheckoutItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const addItem = useCallback((item: CheckoutItem) => {
setItems(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i => i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i);
}
return [...prev, item];
});
}, []);
const removeItem = useCallback((id: string) => {
setItems(prev => prev.filter(i => i.id !== id));
}, []);
const updateQuantity = useCallback((id: string, quantity: number) => {
if (quantity <= 0) {
removeItem(id);
return;
}
setItems(prev => prev.map(i => i.id === id ? { ...i, quantity } : i));
}, [removeItem]);
const processCheckout = useCallback(async () => {
setIsProcessing(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
setItems([]);
return { success: true };
} catch (error) {
return { success: false, error };
} finally {
setIsProcessing(false);
}
}, []);
const total = items.reduce((sum, item) => {
const price = parseFloat(item.price.replace(/[^0-9.-]+/g, ''));
return sum + (price * item.quantity);
}, 0);
return {
items,
addItem,
removeItem,
updateQuantity,
processCheckout,
total,
isProcessing,
};
};
export type CheckoutResult = {
success: boolean;
url?: string;
error?: string;
};
export function useCheckout() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const checkout = async (items: CheckoutItem[], options?: { successUrl?: string; cancelUrl?: string }): Promise<CheckoutResult> => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
if (!apiUrl || !projectId) {
const errorMsg = "NEXT_PUBLIC_API_URL or NEXT_PUBLIC_PROJECT_ID not configured";
setError(errorMsg);
return { success: false, error: errorMsg };
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${apiUrl}/stripe/project/checkout-session`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
projectId,
items,
successUrl: options?.successUrl || window.location.href,
cancelUrl: options?.cancelUrl || window.location.href,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.message || `Request failed with status ${response.status}`;
setError(errorMsg);
return { success: false, error: errorMsg };
}
const data = await response.json();
if (data.data.url) {
window.location.href = data.data.url;
}
return { success: true, url: data.data.url };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to create checkout session";
setError(errorMsg);
return { success: false, error: errorMsg };
} finally {
setIsLoading(false);
}
};
const buyNow = async (product: Product | string, quantity: number = 1): Promise<CheckoutResult> => {
const successUrl = new URL(window.location.href);
successUrl.searchParams.set("success", "true");
if (typeof product === "string") {
return checkout([{ productId: product, quantity }], { successUrl: successUrl.toString() });
}
let metadata: CheckoutItem["metadata"] = {};
if (product.metadata && Object.keys(product.metadata).length > 0) {
const { imageSrc, imageAlt, images, ...restMetadata } = product.metadata;
metadata = restMetadata;
} else {
if (product.brand) metadata.brand = product.brand;
if (product.variant) metadata.variant = product.variant;
if (product.rating !== undefined) metadata.rating = product.rating;
if (product.reviewCount) metadata.reviewCount = product.reviewCount;
}
return checkout([{
productId: product.id,
quantity,
imageSrc: product.imageSrc,
imageAlt: product.imageAlt,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
}], { successUrl: successUrl.toString() });
};
return {
checkout,
buyNow,
isLoading,
error,
clearError: () => setError(null),
};
}

View File

@@ -1,115 +1,39 @@
"use client";
import { useState, useCallback } from 'react';
import { useState, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useProducts } from "./useProducts";
import type { Product } from "@/lib/api/product";
import type { CatalogProduct } from "@/components/ecommerce/productCatalog/ProductCatalogItem";
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
export type SortOption = "Newest" | "Price: Low-High" | "Price: High-Low";
interface UseProductCatalogOptions {
basePath?: string;
export interface CatalogItem {
id: string;
name: string;
price: string;
imageSrc: string;
imageAlt?: string;
category?: string;
}
export function useProductCatalog(options: UseProductCatalogOptions = {}) {
const { basePath = "/shop" } = options;
const router = useRouter();
const { products: fetchedProducts, isLoading } = useProducts();
export const useProductCatalog = () => {
const [items, setItems] = useState<CatalogItem[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [category, setCategory] = useState("All");
const [sort, setSort] = useState<SortOption>("Newest");
const filteredItems = selectedCategory
? items.filter(item => item.category === selectedCategory)
: items;
const handleProductClick = useCallback((productId: string) => {
router.push(`${basePath}/${productId}`);
}, [router, basePath]);
const loadCatalog = useCallback(async () => {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setItems([
{ id: '1', name: 'Product 1', price: '$99', imageSrc: '/placeholder.jpg', category: 'featured' },
{ id: '2', name: 'Product 2', price: '$149', imageSrc: '/placeholder.jpg', category: 'new' },
]);
} catch (error) {
console.error('Failed to load catalog:', error);
}
}, []);
const catalogProducts: CatalogProduct[] = useMemo(() => {
if (fetchedProducts.length === 0) return [];
return fetchedProducts.map((product) => ({
id: product.id,
name: product.name,
price: product.price,
imageSrc: product.imageSrc,
imageAlt: product.imageAlt || product.name,
rating: product.rating || 0,
reviewCount: product.reviewCount,
category: product.brand,
onProductClick: () => handleProductClick(product.id),
}));
}, [fetchedProducts, handleProductClick]);
const categories = useMemo(() => {
const categorySet = new Set<string>();
catalogProducts.forEach((product) => {
if (product.category) {
categorySet.add(product.category);
}
});
return Array.from(categorySet).sort();
}, [catalogProducts]);
const filteredProducts = useMemo(() => {
let result = catalogProducts;
if (search) {
const q = search.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
(p.category?.toLowerCase().includes(q) ?? false)
);
}
if (category !== "All") {
result = result.filter((p) => p.category === category);
}
if (sort === "Price: Low-High") {
result = [...result].sort(
(a, b) =>
parseFloat(a.price.replace("$", "").replace(",", "")) -
parseFloat(b.price.replace("$", "").replace(",", ""))
);
} else if (sort === "Price: High-Low") {
result = [...result].sort(
(a, b) =>
parseFloat(b.price.replace("$", "").replace(",", "")) -
parseFloat(a.price.replace("$", "").replace(",", ""))
);
}
return result;
}, [catalogProducts, search, category, sort]);
const filters: ProductVariant[] = useMemo(() => [
{
label: "Category",
options: ["All", ...categories],
selected: category,
onChange: setCategory,
},
{
label: "Sort",
options: ["Newest", "Price: Low-High", "Price: High-Low"] as SortOption[],
selected: sort,
onChange: (value) => setSort(value as SortOption),
},
], [categories, category, sort]);
return {
products: filteredProducts,
isLoading,
search,
setSearch,
category,
setCategory,
sort,
setSort,
filters,
categories,
};
}
return {
items: filteredItems,
loadCatalog,
selectedCategory,
setSelectedCategory,
};
};

View File

@@ -1,196 +1,42 @@
"use client";
import { useState, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback } from "react";
import { useProduct } from "./useProduct";
import type { Product } from "@/lib/api/product";
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
import type { ExtendedCartItem } from "./useCart";
interface ProductImage {
src: string;
alt: string;
export interface ProductDetail {
id: string;
name: string;
price: string;
description: string;
imageSrc: string;
imageAlt?: string;
specs?: Record<string, string>;
}
interface ProductMeta {
salePrice?: string;
ribbon?: string;
inventoryStatus?: string;
inventoryQuantity?: number;
sku?: string;
}
export const useProductDetail = (id: string) => {
const [product, setProduct] = useState<ProductDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
export function useProductDetail(productId: string) {
const { product, isLoading, error } = useProduct(productId);
const [selectedQuantity, setSelectedQuantity] = useState(1);
const [selectedVariants, setSelectedVariants] = useState<Record<string, string>>({});
const images = useMemo<ProductImage[]>(() => {
if (!product) return [];
if (product.images && product.images.length > 0) {
return product.images.map((src, index) => ({
src,
alt: product.imageAlt || `${product.name} - Image ${index + 1}`,
}));
}
return [{
src: product.imageSrc,
alt: product.imageAlt || product.name,
}];
}, [product]);
const meta = useMemo<ProductMeta>(() => {
if (!product?.metadata) return {};
const metadata = product.metadata;
let salePrice: string | undefined;
const onSaleValue = metadata.onSale;
const onSale = String(onSaleValue) === "true" || onSaleValue === 1 || String(onSaleValue) === "1";
const salePriceValue = metadata.salePrice;
if (onSale && salePriceValue !== undefined && salePriceValue !== null) {
if (typeof salePriceValue === 'number') {
salePrice = `$${salePriceValue.toFixed(2)}`;
} else {
const salePriceStr = String(salePriceValue);
salePrice = salePriceStr.startsWith('$') ? salePriceStr : `$${salePriceStr}`;
}
}
let inventoryQuantity: number | undefined;
if (metadata.inventoryQuantity !== undefined) {
const qty = metadata.inventoryQuantity;
inventoryQuantity = typeof qty === 'number' ? qty : parseInt(String(qty), 10);
}
return {
salePrice,
ribbon: metadata.ribbon ? String(metadata.ribbon) : undefined,
inventoryStatus: metadata.inventoryStatus ? String(metadata.inventoryStatus) : undefined,
inventoryQuantity,
sku: metadata.sku ? String(metadata.sku) : undefined,
};
}, [product]);
const variants = useMemo<ProductVariant[]>(() => {
if (!product) return [];
const variantList: ProductVariant[] = [];
if (product.metadata?.variantOptions) {
try {
const variantOptionsStr = String(product.metadata.variantOptions);
const parsedOptions = JSON.parse(variantOptionsStr);
if (Array.isArray(parsedOptions)) {
parsedOptions.forEach((option: any) => {
if (option.name && option.values) {
const values = typeof option.values === 'string'
? option.values.split(',').map((v: string) => v.trim())
: Array.isArray(option.values)
? option.values.map((v: any) => String(v).trim())
: [String(option.values)];
if (values.length > 0) {
const optionLabel = option.name;
const currentSelected = selectedVariants[optionLabel] || values[0];
variantList.push({
label: optionLabel,
options: values,
selected: currentSelected,
onChange: (value) => {
setSelectedVariants((prev) => ({
...prev,
[optionLabel]: value,
}));
},
});
}
}
});
}
} catch (error) {
console.warn("Failed to parse variantOptions:", error);
}
}
if (variantList.length === 0 && product.brand) {
variantList.push({
label: "Brand",
options: [product.brand],
selected: product.brand,
onChange: () => { },
});
}
if (variantList.length === 0 && product.variant) {
const variantOptions = product.variant.includes('/')
? product.variant.split('/').map(v => v.trim())
: [product.variant];
const variantLabel = "Variant";
const currentSelected = selectedVariants[variantLabel] || variantOptions[0];
variantList.push({
label: variantLabel,
options: variantOptions,
selected: currentSelected,
onChange: (value) => {
setSelectedVariants((prev) => ({
...prev,
[variantLabel]: value,
}));
},
});
}
return variantList;
}, [product, selectedVariants]);
const quantityVariant = useMemo<ProductVariant>(() => ({
label: "Quantity",
options: Array.from({ length: 10 }, (_, i) => String(i + 1)),
selected: String(selectedQuantity),
onChange: (value) => setSelectedQuantity(parseInt(value, 10)),
}), [selectedQuantity]);
const createCartItem = useCallback((): ExtendedCartItem | null => {
if (!product) return null;
const variantStrings = Object.entries(selectedVariants).map(
([label, value]) => `${label}: ${value}`
);
if (variantStrings.length === 0 && product.variant) {
variantStrings.push(`Variant: ${product.variant}`);
}
const variantId = Object.values(selectedVariants).join('-') || 'default';
return {
id: `${product.id}-${variantId}-${selectedQuantity}`,
productId: product.id,
name: product.name,
variants: variantStrings,
price: product.price,
quantity: selectedQuantity,
imageSrc: product.imageSrc,
imageAlt: product.imageAlt || product.name,
};
}, [product, selectedVariants, selectedQuantity]);
return {
product,
isLoading,
error,
images,
meta,
variants,
quantityVariant,
selectedQuantity,
selectedVariants,
createCartItem,
useEffect(() => {
const loadProduct = async () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
setProduct({
id,
name: 'Product',
price: '$99',
description: 'High quality product',
imageSrc: '/placeholder.jpg',
});
} catch (err) {
setError('Failed to load product');
} finally {
setIsLoading(false);
}
};
}
loadProduct();
}, [id]);
return { product, isLoading, error };
};

View File

@@ -1,219 +1,43 @@
export type Product = {
id: string;
name: string;
price: string;
imageSrc: string;
imageAlt?: string;
images?: string[];
brand?: string;
variant?: string;
rating?: number;
reviewCount?: string;
description?: string;
priceId?: string;
metadata?: {
[key: string]: string | number | undefined;
};
onFavorite?: () => void;
onProductClick?: () => void;
isFavorited?: boolean;
export interface ProductResponse {
id: string;
name: string;
price: number;
description: string;
imageSrc: string;
}
export const fetchProduct = async (id: string): Promise<ProductResponse> => {
try {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch product');
}
return response.json();
} catch {
throw new Error('Failed to fetch product');
}
};
export const defaultProducts: Product[] = [
{
id: "1",
name: "Classic White Sneakers",
price: "$129",
brand: "Nike",
variant: "White / Size 42",
rating: 4.5,
reviewCount: "128",
imageSrc: "/placeholders/placeholder3.avif",
imageAlt: "Classic white sneakers",
},
{
id: "2",
name: "Leather Crossbody Bag",
price: "$89",
brand: "Coach",
variant: "Brown / Medium",
rating: 4.8,
reviewCount: "256",
imageSrc: "/placeholders/placeholder4.webp",
imageAlt: "Brown leather crossbody bag",
},
{
id: "3",
name: "Wireless Headphones",
price: "$199",
brand: "Sony",
variant: "Black",
rating: 4.7,
reviewCount: "512",
imageSrc: "/placeholders/placeholder3.avif",
imageAlt: "Black wireless headphones",
},
{
id: "4",
name: "Minimalist Watch",
price: "$249",
brand: "Fossil",
variant: "Silver / 40mm",
rating: 4.6,
reviewCount: "89",
imageSrc: "/placeholders/placeholder4.webp",
imageAlt: "Silver minimalist watch",
},
];
function formatPrice(amount: number, currency: string): string {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
return formatter.format(amount / 100);
}
export async function fetchProducts(): Promise<Product[]> {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
if (!apiUrl || !projectId) {
return [];
export const fetchProducts = async (): Promise<ProductResponse[]> => {
try {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
return response.json();
} catch {
throw new Error('Failed to fetch products');
}
};
try {
const url = `${apiUrl}/stripe/project/products?projectId=${projectId}&expandDefaultPrice=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
return [];
}
const resp = await response.json();
const data = resp.data.data || resp.data;
if (!Array.isArray(data) || data.length === 0) {
return [];
}
return data.map((product: any) => {
const metadata: Record<string, string | number | undefined> = {};
if (product.metadata && typeof product.metadata === 'object') {
Object.keys(product.metadata).forEach(key => {
const value = product.metadata[key];
if (value !== null && value !== undefined) {
const numValue = parseFloat(value);
metadata[key] = isNaN(numValue) ? value : numValue;
}
});
}
const imageSrc = product.images?.[0] || product.imageSrc || "/placeholders/placeholder3.avif";
const imageAlt = product.imageAlt || product.name || "";
const images = product.images && Array.isArray(product.images) && product.images.length > 0
? product.images
: [imageSrc];
return {
id: product.id || String(Math.random()),
name: product.name || "Untitled Product",
description: product.description || "",
price: product.default_price?.unit_amount
? formatPrice(product.default_price.unit_amount, product.default_price.currency || "usd")
: product.price || "$0",
priceId: product.default_price?.id || product.priceId,
imageSrc,
imageAlt,
images,
brand: product.metadata?.brand || product.brand || "",
variant: product.metadata?.variant || product.variant || "",
rating: product.metadata?.rating ? parseFloat(product.metadata.rating) : undefined,
reviewCount: product.metadata?.reviewCount || undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
};
});
} catch (error) {
return [];
export const fetchProductsByCategory = async (category: string): Promise<ProductResponse[]> => {
try {
const response = await fetch(`/api/products?category=${category}`);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
}
export async function fetchProduct(productId: string): Promise<Product | null> {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
if (!apiUrl || !projectId) {
return null;
}
try {
const url = `${apiUrl}/stripe/project/products/${productId}?projectId=${projectId}&expandDefaultPrice=true`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
return null;
}
const resp = await response.json();
const product = resp.data?.data || resp.data || resp;
if (!product || typeof product !== 'object') {
return null;
}
const metadata: Record<string, string | number | undefined> = {};
if (product.metadata && typeof product.metadata === 'object') {
Object.keys(product.metadata).forEach(key => {
const value = product.metadata[key];
if (value !== null && value !== undefined && value !== '') {
const numValue = parseFloat(String(value));
metadata[key] = isNaN(numValue) ? String(value) : numValue;
}
});
}
let priceValue = product.price;
if (!priceValue && product.default_price?.unit_amount) {
priceValue = formatPrice(product.default_price.unit_amount, product.default_price.currency || "usd");
}
if (!priceValue) {
priceValue = "$0";
}
const imageSrc = product.images?.[0] || product.imageSrc || "/placeholders/placeholder3.avif";
const imageAlt = product.imageAlt || product.name || "";
const images = product.images && Array.isArray(product.images) && product.images.length > 0
? product.images
: [imageSrc];
return {
id: product.id || String(Math.random()),
name: product.name || "Untitled Product",
description: product.description || "",
price: priceValue,
priceId: product.default_price?.id || product.priceId,
imageSrc,
imageAlt,
images,
brand: product.metadata?.brand || product.brand || "",
variant: product.metadata?.variant || product.variant || "",
rating: product.metadata?.rating ? parseFloat(String(product.metadata.rating)) : undefined,
reviewCount: product.metadata?.reviewCount || undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
};
} catch (error) {
return null;
}
}
return response.json();
} catch {
throw new Error('Failed to fetch products');
}
};