Merge version_1 into main #3

Merged
bender merged 12 commits from version_1 into main 2026-03-11 18:34:29 +00:00
12 changed files with 577 additions and 1650 deletions

View File

@@ -1,7 +1,6 @@
"use client"
"use client";
import { Brain, HardDrive, Shield, Zap } from "lucide-react";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import { ThemeProvider } from '@/providers/themeProvider/ThemeProvider';
import NavbarLayoutFloatingOverlay from '@/components/navbar/NavbarLayoutFloatingOverlay/NavbarLayoutFloatingOverlay';
import HeroLogo from '@/components/sections/hero/HeroLogo';
import TextAbout from '@/components/sections/about/TextAbout';
@@ -12,32 +11,51 @@ import TestimonialCardSixteen from '@/components/sections/testimonial/Testimonia
import FaqSplitText from '@/components/sections/faq/FaqSplitText';
import ContactSplitForm from '@/components/sections/contact/ContactSplitForm';
import FooterBaseCard from '@/components/sections/footer/FooterBaseCard';
import { Shield, Brain, HardDrive, Zap, Package } from 'lucide-react';
export default function LandingPage() {
const assetMap: Record<string, string> = {
'hero-dashboard': 'https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/a-premium-discord-security-dashboard-int-1773253913572-d727d9ee.png',
'feature-antinuke': 'https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/visual-representation-of-antinuke-protec-1773253912702-75413cab.png',
'feature-automod': 'https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/ai-powered-content-moderation-system-int-1773253912974-892bfd2f.png',
'feature-backup': 'https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/server-backup-and-recovery-system-interf-1773253913333-eafcf1ca.png',
'testimonial-1': 'http://img.b2bpic.net/free-photo/side-view-smiley-people-playing-videogame_23-2149349993.jpg',
'testimonial-2': 'http://img.b2bpic.net/free-photo/afroamerican-businessman-wearing-headphones_23-2148508923.jpg',
'testimonial-3': 'http://img.b2bpic.net/free-photo/front-view-man-posing-studio_23-2150275662.jpg',
'testimonial-4': 'http://img.b2bpic.net/free-photo/young-chinese-woman-smiling-confident-standing-street_839833-7633.jpg',
'contact-form-bg': 'https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/premium-contact-interface-showing-secure-1773253914940-41b5b955.png',
'discord-logo': 'http://img.b2bpic.net/free-vector/popular-social-media-icons_1057-4291.jpg',
};
const getAssetUrl = (assetId: string): string => {
return assetMap[assetId] || '/placeholders/placeholder1.webp';
};
export default function Home() {
return (
<ThemeProvider
defaultButtonVariant="icon-arrow"
defaultButtonVariant="text-stagger"
defaultTextAnimation="entrance-slide"
borderRadius="pill"
borderRadius="rounded"
contentWidth="medium"
sizing="largeSmall"
background="floatingGradient"
sizing="medium"
background="circleGradient"
cardStyle="glass-elevated"
primaryButtonStyle="double-inset"
secondaryButtonStyle="solid"
primaryButtonStyle="gradient"
secondaryButtonStyle="glass"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarLayoutFloatingOverlay
brandName="KavachBot"
navItems={[
{ name: "Features", id: "features" },
{ name: "Security", id: "security" },
{ name: "Pricing", id: "pricing" },
{ name: "About", id: "about" },
{ name: "Support", id: "contact" }
{ name: "Products", id: "products" },
{ name: "Testimonials", id: "testimonials" },
{ name: "FAQ", id: "faq" },
{ name: "Contact", id: "contact" },
]}
button={{ text: "Invite Bot", href: "https://discord.com" }}
brandName="KavachBot"
button={{
text: "Invite Bot", href: "https://discord.com"}}
/>
</div>
@@ -47,9 +65,9 @@ export default function LandingPage() {
description="The most advanced Discord security bot — AI-powered AntiNuke protection, automated moderation, and enterprise-grade server protection completely free"
buttons={[
{ text: "Invite Bot Now", href: "https://discord.com" },
{ text: "Join Support Server", href: "#contact" }
{ text: "Join Support Server", href: "#contact" },
]}
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/a-premium-discord-security-dashboard-int-1773253913572-d727d9ee.png"
imageSrc={getAssetUrl('hero-dashboard')}
imageAlt="KavachBot Premium Dashboard"
showDimOverlay={true}
buttonAnimation="slide-up"
@@ -64,7 +82,7 @@ export default function LandingPage() {
useInvertedBackground={false}
buttons={[
{ text: "Explore Features", href: "#features" },
{ text: "View Documentation", href: "https://docs.kavachbot.com" }
{ text: "View Documentation", href: "https://docs.kavachbot.com" },
]}
/>
</div>
@@ -76,21 +94,21 @@ export default function LandingPage() {
tag="Enterprise Protection"
features={[
{
title: "AntiNuke Protection", description: "Real-time detection of mass bans, channel deletions, role removals, and unauthorized permission changes with instant reversion", imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/visual-representation-of-antinuke-protec-1773253912702-75413cab.png?_wi=1", imageAlt: "AntiNuke Protection", buttonIcon: Shield,
buttonHref: "#contact"
},
title: "AntiNuke Protection", description: "Real-time detection of mass bans, channel deletions, role removals, and unauthorized permission changes with instant reversion", imageSrc: getAssetUrl('feature-antinuke'),
imageAlt: "AntiNuke Protection", buttonIcon: Shield,
buttonHref: "#contact"},
{
title: "AI-Powered Automod", description: "Advanced spam filtering, phishing detection, and content moderation powered by machine learning for intelligent threat prevention", imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/ai-powered-content-moderation-system-int-1773253912974-892bfd2f.png", imageAlt: "AI Automod System", buttonIcon: Brain,
buttonHref: "#contact"
},
title: "AI-Powered Automod", description: "Advanced spam filtering, phishing detection, and content moderation powered by machine learning for intelligent threat prevention", imageSrc: getAssetUrl('feature-automod'),
imageAlt: "AI Automod System", buttonIcon: Brain,
buttonHref: "#contact"},
{
title: "Server Backups", description: "Automated role, channel, and configuration backups with extended retention and one-click restoration for complete server recovery", imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/server-backup-and-recovery-system-interf-1773253913333-eafcf1ca.png", imageAlt: "Server Backup System", buttonIcon: HardDrive,
buttonHref: "#contact"
},
title: "Server Backups", description: "Automated role, channel, and configuration backups with extended retention and one-click restoration for complete server recovery", imageSrc: getAssetUrl('feature-backup'),
imageAlt: "Server Backup System", buttonIcon: HardDrive,
buttonHref: "#contact"},
{
title: "Advanced Moderation", description: "Comprehensive moderation tools including automated actions, custom workflows, and sophisticated permission management controls", imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/visual-representation-of-antinuke-protec-1773253912702-75413cab.png?_wi=2", imageAlt: "Moderation Tools", buttonIcon: Zap,
buttonHref: "#contact"
}
title: "Advanced Moderation", description: "Comprehensive moderation tools including automated actions, custom workflows, and sophisticated permission management controls", imageSrc: getAssetUrl('feature-antinuke'),
imageAlt: "Moderation Tools", buttonIcon: Zap,
buttonHref: "#contact"},
]}
textboxLayout="default"
useInvertedBackground={false}
@@ -105,14 +123,17 @@ export default function LandingPage() {
tag="Verified Official"
products={[
{
id: "kavach-main", name: "Kavach", price: "FREE", variant: "AntiNuke • Automod • Moderation", imageSrc: "http://img.b2bpic.net/free-vector/popular-social-media-icons_1057-4291.jpg?_wi=1", imageAlt: "Kavach Main Bot", isFavorited: false
id: "kavach-main", name: "Kavach", price: "FREE", variant: "AntiNuke • Automod • Moderation", imageSrc: getAssetUrl('discord-logo'),
imageAlt: "Kavach Main Bot", isFavorited: false,
},
{
id: "kavach-premium", name: "Kavach Premium", price: "PREMIUM", variant: "JoinGate • Extended Backups • Priority Support", imageSrc: "http://img.b2bpic.net/free-vector/popular-social-media-icons_1057-4291.jpg?_wi=2", imageAlt: "Kavach Premium Bot", isFavorited: false
id: "kavach-premium", name: "Kavach Premium", price: "PREMIUM", variant: "JoinGate • Extended Backups • Priority Support", imageSrc: getAssetUrl('discord-logo'),
imageAlt: "Kavach Premium Bot", isFavorited: false,
},
{
id: "suno-music", name: "Suno Music Bot", price: "FREE", variant: "Music Streaming • Audio Control • Queue Management", imageSrc: "http://img.b2bpic.net/free-vector/popular-social-media-icons_1057-4291.jpg?_wi=3", imageAlt: "Suno Music Bot", isFavorited: false
}
id: "suno-music", name: "Suno Music Bot", price: "FREE", variant: "Music Streaming • Audio Control • Queue Management", imageSrc: getAssetUrl('discord-logo'),
imageAlt: "Suno Music Bot", isFavorited: false,
},
]}
gridVariant="uniform-all-items-equal"
animationType="slide-up"
@@ -129,7 +150,7 @@ export default function LandingPage() {
{ id: "servers-protected", value: "50K+", description: "Discord Servers Protected" },
{ id: "attacks-prevented", value: "1M+", description: "Attacks Prevented Monthly" },
{ id: "users-protected", value: "10M+", description: "Users Safeguarded" },
{ id: "uptime", value: "99.9%", description: "Enterprise Uptime" }
{ id: "uptime", value: "99.9%", description: "Enterprise Uptime" },
]}
gridVariant="uniform-all-items-equal"
animationType="slide-up"
@@ -145,25 +166,25 @@ export default function LandingPage() {
testimonials={[
{
id: "1", name: "Sarah Chen", role: "Server Administrator", company: "Tech Community Discord", rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/side-view-smiley-people-playing-videogame_23-2149349993.jpg", imageAlt: "Sarah Chen"
},
imageSrc: getAssetUrl('testimonial-1'),
imageAlt: "Sarah Chen"},
{
id: "2", name: "Marcus Rodriguez", role: "Community Manager", company: "Gaming Hub Server", rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/afroamerican-businessman-wearing-headphones_23-2148508923.jpg", imageAlt: "Marcus Rodriguez"
},
imageSrc: getAssetUrl('testimonial-2'),
imageAlt: "Marcus Rodriguez"},
{
id: "3", name: "Emily Watson", role: "Security Lead", company: "Developer Network", rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/front-view-man-posing-studio_23-2150275662.jpg", imageAlt: "Emily Watson"
},
imageSrc: getAssetUrl('testimonial-3'),
imageAlt: "Emily Watson"},
{
id: "4", name: "Alex Thompson", role: "Server Owner", company: "Creative Collective", rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/young-chinese-woman-smiling-confident-standing-street_839833-7633.jpg", imageAlt: "Alex Thompson"
}
imageSrc: getAssetUrl('testimonial-4'),
imageAlt: "Alex Thompson"},
]}
kpiItems={[
{ value: "99%", label: "Threat Detection Rate" },
{ value: "<1s", label: "Average Response Time" },
{ value: "24/7", label: "Active Protection" }
{ value: "24/7", label: "Active Protection" },
]}
animationType="slide-up"
textboxLayout="default"
@@ -177,23 +198,17 @@ export default function LandingPage() {
sideDescription="Everything you need to know about KavachBot premium security features"
faqs={[
{
id: "1", title: "Is KavachBot really free?", content: "Yes, KavachBot core security features including AntiNuke, Automod, moderation, backups, logging, and verification are completely free. We offer KavachBot Premium for additional features like JoinGate raid filtering and extended backup retention."
},
id: "1", title: "Is KavachBot really free?", content: "Yes, KavachBot core security features including AntiNuke, Automod, moderation, backups, logging, and verification are completely free. We offer KavachBot Premium for additional features like JoinGate raid filtering and extended backup retention."},
{
id: "2", title: "How does AntiNuke protection work?", content: "AntiNuke provides real-time detection of mass bans, channel deletions, role removals, and unauthorized permission changes. When an attack is detected, Kavach instantly reverts actions and strips the attacker's permissions using our advanced permit whitelist system."
},
id: "2", title: "How does AntiNuke protection work?", content: "AntiNuke provides real-time detection of mass bans, channel deletions, role removals, and unauthorized permission changes. When an attack is detected, Kavach instantly reverts actions and strips the attacker's permissions using our advanced permit whitelist system."},
{
id: "3", title: "Can I restore my server from backups?", content: "Yes. KavachBot automatically backs up your roles, channels, and configurations. You can restore your server to any previous backup point with a single command, providing complete disaster recovery."
},
id: "3", title: "Can I restore my server from backups?", content: "Yes. KavachBot automatically backs up your roles, channels, and configurations. You can restore your server to any previous backup point with a single command, providing complete disaster recovery."},
{
id: "4", title: "Does KavachBot support custom moderation workflows?", content: "KavachBot provides comprehensive moderation tools with automated actions and permission management. Premium features allow for extended customization and advanced security rules tailored to your server."
},
id: "4", title: "Does KavachBot support custom moderation workflows?", content: "KavachBot provides comprehensive moderation tools with automated actions and permission management. Premium features allow for extended customization and advanced security rules tailored to your server."},
{
id: "5", title: "What about spam and phishing protection?", content: "Our AI-powered Automod system provides advanced spam filtering and phishing detection, automatically removing malicious content and protecting your community from coordinated attacks."
},
id: "5", title: "What about spam and phishing protection?", content: "Our AI-powered Automod system provides advanced spam filtering and phishing detection, automatically removing malicious content and protecting your community from coordinated attacks."},
{
id: "6", title: "Is there priority support available?", content: "Yes. KavachBot Premium subscribers receive priority support on our official support server, ensuring faster response times for enterprise customers and large-scale deployments."
}
id: "6", title: "Is there priority support available?", content: "Yes. KavachBot Premium subscribers receive priority support on our official support server, ensuring faster response times for enterprise customers and large-scale deployments."},
]}
useInvertedBackground={false}
animationType="smooth"
@@ -208,11 +223,14 @@ export default function LandingPage() {
description="Invite KavachBot to your server today and experience enterprise-grade security. Have questions? Our support team is here to help."
inputs={[
{ name: "serverName", type: "text", placeholder: "Your Server Name", required: true },
{ name: "email", type: "email", placeholder: "Your Email Address", required: true }
{ name: "email", type: "email", placeholder: "Your Email Address", required: true },
]}
textarea={{ name: "message", placeholder: "How can we help protect your server?", rows: 5, required: true }}
textarea={{
name: "message", placeholder: "How can we help protect your server?", rows: 5,
required: true,
}}
useInvertedBackground={false}
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AoIhdjka20PXTM8228xExsAGIl/premium-contact-interface-showing-secure-1773253914940-41b5b955.png"
imageSrc={getAssetUrl('contact-form-bg')}
imageAlt="KavachBot Support"
mediaAnimation="slide-up"
mediaPosition="right"
@@ -229,34 +247,34 @@ export default function LandingPage() {
{ label: "AntiNuke Protection", href: "#features" },
{ label: "AI Automod", href: "#features" },
{ label: "Server Backups", href: "#features" },
{ label: "Advanced Moderation", href: "#features" }
]
{ label: "Advanced Moderation", href: "#features" },
],
},
{
title: "Official Bots", items: [
{ label: "Kavach Main", href: "https://discord.com" },
{ label: "Kavach Premium", href: "https://discord.com" },
{ label: "Suno Music Bot", href: "https://discord.com" }
]
{ label: "Suno Music Bot", href: "https://discord.com" },
],
},
{
title: "Support", items: [
{ label: "Documentation", href: "https://docs.kavachbot.com" },
{ label: "Support Server", href: "https://discord.gg/kavachbot" },
{ label: "Report Issues", href: "mailto:support@kavachbot.com" }
]
{ label: "Report Issues", href: "mailto:support@kavachbot.com" },
],
},
{
title: "Legal", items: [
{ label: "Privacy Policy", href: "#" },
{ label: "Terms of Service", href: "#" },
{ label: "Contact Us", href: "mailto:legal@kavachbot.com" }
]
}
{ label: "Contact Us", href: "mailto:legal@kavachbot.com" },
],
},
]}
copyrightText="© 2025 KavachBot. Premium Discord Security."
/>
</div>
</ThemeProvider>
);
}
}

View File

@@ -1,118 +1,46 @@
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;
interface UseDepth3DAnimationProps {
itemRefs: RefObject<(HTMLElement | null)[]>;
containerRef: RefObject<HTMLDivElement | null>;
perspectiveRef?: RefObject<HTMLDivElement | null>;
isEnabled: boolean;
interface AnimationFrame {
rotationX: number;
rotationY: number;
scale: number;
}
export const useDepth3DAnimation = ({
itemRefs,
containerRef,
perspectiveRef,
isEnabled,
}: UseDepth3DAnimationProps) => {
const [isMobile, setIsMobile] = useState(false);
const useDepth3DAnimation = (isActive: boolean): AnimationFrame => {
const [animation, setAnimation] = useState<AnimationFrame>({
rotationX: 0,
rotationY: 0,
scale: 1,
});
// Detect mobile viewport
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
// 3D mouse-tracking effect (desktop only)
useEffect(() => {
if (!isEnabled || isMobile) return;
let animationFrameId: number;
let isAnimating = true;
// Apply perspective to the perspective ref (grid) if provided, otherwise to container (section)
const perspectiveElement = perspectiveRef?.current || containerRef.current;
if (perspectiveElement) {
perspectiveElement.style.perspective = "1200px";
perspectiveElement.style.transformStyle = "preserve-3d";
if (!isActive) {
setAnimation({ rotationX: 0, rotationY: 0, scale: 1 });
return;
}
let mouseX = 0;
let mouseY = 0;
let isMouseInSection = false;
const handleMouseMove = (e: MouseEvent) => {
const x = e.clientX;
const y = e.clientY;
let currentX = 0;
let currentY = 0;
let currentRotationX = 0;
let currentRotationY = 0;
const rotX = (y / window.innerHeight) * 20 - 10;
const rotY = (x / window.innerWidth) * 20 - 10;
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)`;
setAnimation({
rotationX: rotX,
rotationY: rotY,
scale: 1.05,
});
animationFrameId = requestAnimationFrame(animate);
};
animate();
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
isAnimating = false;
window.removeEventListener('mousemove', handleMouseMove);
};
}, [isEnabled, isMobile, itemRefs, containerRef]);
}, [isActive]);
return { isMobile };
return animation;
};
export default useDepth3DAnimation;

View File

@@ -1,149 +1,45 @@
"use client";
import React from 'react';
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";
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;
interface TimelineItem {
id: string;
title: string;
description: string;
date?: string;
icon?: React.ReactNode;
}
const TimelineBase = ({
children,
variant = "timeline",
uniformGridCustomHeightClasses = "min-h-80 2xl:min-h-90",
animationType,
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);
}, []);
interface TimelineBaseProps {
items: TimelineItem[];
direction?: 'vertical' | 'horizontal';
className?: string;
}
const TimelineBase: React.FC<TimelineBaseProps> = ({
items,
direction = 'vertical',
className = '',
}) => {
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground && "bg-foreground",
className
)}
aria-label={ariaLabel}
<div
className={`timeline timeline-${direction} ${className}`}
role="list"
>
<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}
/>
)}
{items.map((item) => (
<div
className={cls(
"relative z-10 flex flex-col gap-6 md:gap-15"
)}
key={item.id}
className="timeline-item"
role="listitem"
>
{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>
))}
{item.icon && <div className="timeline-icon">{item.icon}</div>}
<div className="timeline-content">
<h3 className="timeline-title">{item.title}</h3>
{item.date && <p className="timeline-date">{item.date}</p>}
<p className="timeline-description">{item.description}</p>
</div>
</div>
</div>
</section>
))}
</div>
);
};
TimelineBase.displayName = "TimelineBase";
export default React.memo(TimelineBase);
export default TimelineBase;

View File

@@ -1,131 +1,59 @@
"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";
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" }
>;
import React, { useState } from 'react';
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;
title: string;
description: string;
inputs?: Array<{
name: string;
type: string;
placeholder: string;
required?: boolean;
}>;
className?: 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) => {
const ContactCenter: React.FC<ContactCenterProps> = ({
title,
description,
inputs = [],
className = '',
}) => {
const [formData, setFormData] = useState<Record<string, string>>({});
const handleSubmit = async (email: string) => {
try {
await sendContactEmail({ email });
console.log("Email send successfully");
} catch (error) {
console.error("Failed to send email:", error);
}
};
const handleInputChange = (name: string, value: string) => {
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>
</div>
</section>
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', formData);
};
return (
<div className={`contact-center ${className}`}>
<h2>{title}</h2>
<p>{description}</p>
<form onSubmit={handleSubmit}>
{inputs.map((input) => (
<div key={input.name}>
<input
type={input.type}
name={input.name}
placeholder={input.placeholder}
required={input.required}
value={formData[input.name] || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
/>
</div>
))}
<button type="submit">Submit</button>
</form>
</div>
);
};
ContactCenter.displayName = "ContactCenter";
export default ContactCenter;

View File

@@ -1,171 +1,69 @@
"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";
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" }
>;
import React, { useState } from 'react';
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;
title: string;
description: string;
imageSrc?: string;
inputs?: Array<{
name: string;
type: string;
placeholder: string;
required?: boolean;
}>;
className?: 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 });
const ContactSplit: React.FC<ContactSplitProps> = ({
title,
description,
imageSrc,
inputs = [],
className = '',
}) => {
const [formData, setFormData] = useState<Record<string, string>>({});
const handleSubmit = async (email: string) => {
try {
await sendContactEmail({ email });
console.log("Email send successfully");
} catch (error) {
console.error("Failed to send email:", error);
}
};
const handleInputChange = (name: string, value: string) => {
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 handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', formData);
};
return (
<div className={`contact-split ${className}`}>
<div className="contact-form">
<h2>{title}</h2>
<p>{description}</p>
<form onSubmit={handleSubmit}>
{inputs.map((input) => (
<div key={input.name}>
<input
type={input.type}
name={input.name}
placeholder={input.placeholder}
required={input.required}
value={formData[input.name] || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
/>
</div>
</div>
);
))}
<button type="submit">Submit</button>
</form>
</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)}
/>
{imageSrc && (
<div className="contact-image">
<img src={imageSrc} alt="Contact" />
</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>
</section>
);
)}
</div>
);
};
ContactSplit.displayName = "ContactSplit";
export default ContactSplit;

View File

@@ -1,214 +1,100 @@
"use client";
import React, { useState } from 'react';
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";
export interface InputField {
interface ContactSplitFormProps {
title: string;
description: string;
inputs: Array<{
name: string;
type: string;
placeholder: string;
required?: boolean;
className?: string;
}
export interface TextareaField {
}>;
textarea?: {
name: string;
placeholder: string;
rows?: number;
required?: boolean;
className?: string;
};
imageSrc?: string;
imageAlt?: string;
mediaAnimation?: string;
mediaPosition?: 'left' | 'right';
buttonText?: string;
useInvertedBackground?: boolean;
className?: string;
}
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 ContactSplitForm: React.FC<ContactSplitFormProps> = ({
title,
description,
inputs,
textarea,
imageSrc,
imageAlt = '',
mediaAnimation = 'slide-up',
mediaPosition = 'right',
buttonText = 'Submit',
useInvertedBackground = false,
className = '',
}) => {
const [formData, setFormData] = useState<Record<string, string>>({});
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 handleInputChange = (name: string, value: string) => {
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
// Validate minimum inputs requirement
if (inputs.length < 2) {
throw new Error("ContactSplitForm requires at least 2 inputs");
}
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form data:', formData);
};
// Initialize form data dynamically
const initialFormData: Record<string, string> = {};
inputs.forEach(input => {
initialFormData[input.name] = "";
});
if (textarea) {
initialFormData[textarea.name] = "";
}
return (
<div className={`contact-split-form ${className}`}>
<div className="form-container">
<h2>{title}</h2>
<p>{description}</p>
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>
<form onSubmit={handleFormSubmit}>
{inputs.map((input) => (
<div key={input.name}>
<input
type={input.type}
name={input.name}
placeholder={input.placeholder}
required={input.required}
value={formData[input.name] || ''}
onChange={(e) => handleInputChange(input.name, e.target.value)}
/>
</div>
</section>
);
))}
{textarea && (
<div>
<textarea
name={textarea.name}
placeholder={textarea.placeholder}
rows={textarea.rows || 4}
required={textarea.required}
value={formData[textarea.name] || ''}
onChange={(e) => handleInputChange(textarea.name, e.target.value)}
/>
</div>
)}
<button type="submit">{buttonText}</button>
</form>
</div>
{imageSrc && (
<div className={`media-container media-${mediaPosition}`}>
<img src={imageSrc} alt={imageAlt} />
</div>
)}
</div>
);
};
ContactSplitForm.displayName = "ContactSplitForm";
export default ContactSplitForm;

View File

@@ -1,248 +1,39 @@
"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";
type PricingPlan = {
id: string;
badge: string;
badgeIcon?: LucideIcon;
price: string;
subtitle: string;
buttons: ButtonConfig[];
features: string[];
};
import React from 'react';
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;
title: string;
price: string;
description: string;
features: string[];
buttonText?: string;
onButtonClick?: () => void;
className?: string;
}
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>
</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>
);
const PricingCardEight: React.FC<PricingCardEightProps> = ({
title,
price,
description,
features,
buttonText = 'Get Started',
onButtonClick,
className = '',
}) => {
return (
<div className={`pricing-card ${className}`}>
<h3>{title}</h3>
<p className="description">{description}</p>
<div className="price-section">
<span className="price">{price}</span>
</div>
<ul className="features-list">
{features.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
<button onClick={onButtonClick}>{buttonText}</button>
</div>
);
};
PricingCardEight.displayName = "PricingCardEight";
export default PricingCardEight;

View File

@@ -18,11 +18,10 @@ const SvgTextLogo: React.FC<SvgTextLogoProps> = ({
textClassName = '',
}) => {
const words = text.split(' ');
let currentX = 0;
const padding = 20;
const lineHeight = fontSize * 1.2;
const maxWidth = 800;
let lines: Array<{ words: string[]; width: number }> = [];
const lines: Array<{ words: string[]; width: number }> = [];
let currentLine: string[] = [];
let currentLineWidth = 0;

View File

@@ -1,117 +1,66 @@
"use client";
import { useState } from 'react';
import { useState } from "react";
import { Product } from "@/lib/api/product";
interface CheckoutItem {
id: string;
name: string;
price: number;
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 [total, setTotal] = useState(0);
const addItem = (item: CheckoutItem) => {
setItems((prev) => [...prev, item]);
setTotal((prev) => prev + item.price * item.quantity);
};
const removeItem = (itemId: string) => {
const item = items.find((i) => i.id === itemId);
if (item) {
setTotal((prev) => prev - item.price * item.quantity);
}
setItems((prev) => prev.filter((i) => i.id !== itemId));
};
const updateQuantity = (itemId: string, quantity: number) => {
const item = items.find((i) => i.id === itemId);
if (item) {
const diff = quantity - item.quantity;
setTotal((prev) => prev + item.price * diff);
setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, quantity } : i))
);
}
};
const checkout = async () => {
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items, total }),
});
if (!response.ok) {
throw new Error('Checkout failed');
}
const data = await response.json();
return data;
} catch (err) {
console.error('Checkout error:', err);
throw err;
}
};
return {
items,
total,
addItem,
removeItem,
updateQuantity,
checkout,
};
};
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,36 @@
"use client";
import { useState, useEffect } 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;
interface CatalogItem {
id: string;
name: string;
category: string;
price: number;
image: 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [category, setCategory] = useState("All");
const [sort, setSort] = useState<SortOption>("Newest");
const handleProductClick = useCallback((productId: string) => {
router.push(`${basePath}/${productId}`);
}, [router, basePath]);
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)
);
useEffect(() => {
const fetchCatalog = async () => {
try {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Failed to fetch catalog');
}
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,
const data = await response.json();
setItems(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
}
fetchCatalog();
}, []);
return { items, loading, error };
};

View File

@@ -1,196 +1,39 @@
"use client";
import { useState, 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;
interface ProductDetail {
id: string;
name: string;
description: string;
price: number;
images: string[];
specifications: Record<string, string>;
}
interface ProductMeta {
salePrice?: string;
ribbon?: string;
inventoryStatus?: string;
inventoryQuantity?: number;
sku?: string;
}
export const useProductDetail = (productId: string) => {
const [product, setProduct] = useState<ProductDetail | null>(null);
const [loading, setLoading] = useState(true);
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}`,
}));
useEffect(() => {
const fetchProduct = async () => {
try {
const response = await fetch(`/api/products/${productId}`);
if (!response.ok) {
throw new Error('Failed to fetch product');
}
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,
const data = await response.json();
setProduct(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
}
if (productId) {
fetchProduct();
}
}, [productId]);
return { product, loading, error };
};

View File

@@ -1,219 +1,89 @@
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;
};
interface ProductResponse {
id: string;
name: string;
price: number;
description: string;
}
export const defaultProducts: Product[] = [
{
id: "1",
name: "Classic White Sneakers",
price: "$129",
brand: "Nike",
variant: "White / Size 42",
rating: 4.5,
reviewCount: "128",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/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: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder4.webp",
imageAlt: "Brown leather crossbody bag",
},
{
id: "3",
name: "Wireless Headphones",
price: "$199",
brand: "Sony",
variant: "Black",
rating: 4.7,
reviewCount: "512",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder3.avif",
imageAlt: "Black wireless headphones",
},
{
id: "4",
name: "Minimalist Watch",
price: "$249",
brand: "Fossil",
variant: "Silver / 40mm",
rating: 4.6,
reviewCount: "89",
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder4.webp",
imageAlt: "Silver minimalist watch",
},
];
export async function getProduct(id: string): Promise<ProductResponse> {
try {
const response = await fetch(`https://api.example.com/products/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (err) {
console.error('Failed to fetch product:', err);
throw err;
}
}
function formatPrice(amount: number, currency: string): string {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
maximumFractionDigits: 2,
export async function getProducts(): Promise<ProductResponse[]> {
try {
const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (err) {
console.error('Failed to fetch products:', err);
throw err;
}
}
export async function createProduct(
product: Omit<ProductResponse, 'id'>
): Promise<ProductResponse> {
try {
const response = await fetch('https://api.example.com/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
return formatter.format(amount / 100);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (err) {
console.error('Failed to create product:', err);
throw err;
}
}
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 [];
}
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 || "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/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 async function updateProduct(
id: string,
product: Partial<ProductResponse>
): Promise<ProductResponse> {
try {
const response = await fetch(`https://api.example.com/products/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (err) {
console.error('Failed to update product:', err);
throw err;
}
}
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;
export async function deleteProduct(id: string): Promise<void> {
try {
const response = await fetch(`https://api.example.com/products/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
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 || "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/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;
}
}
} catch (err) {
console.error('Failed to delete product:', err);
throw err;
}
}