Merge version_1 into main #4
114
src/app/page.tsx
114
src/app/page.tsx
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarLayoutFloatingInline from '@/components/navbar/NavbarLayoutFloatingInline';
|
||||
import HeroSplitTestimonial from '@/components/sections/hero/HeroSplitTestimonial';
|
||||
import ProductCardTwo from '@/components/sections/product/ProductCardTwo';
|
||||
import InlineImageSplitTextAbout from '@/components/sections/about/InlineImageSplitTextAbout';
|
||||
@@ -10,32 +9,35 @@ import TestimonialCardTwelve from '@/components/sections/testimonial/Testimonial
|
||||
import SocialProofOne from '@/components/sections/socialProof/SocialProofOne';
|
||||
import FaqSplitMedia from '@/components/sections/faq/FaqSplitMedia';
|
||||
import FooterBaseReveal from '@/components/sections/footer/FooterBaseReveal';
|
||||
import { Award, CheckCircle, Globe, Heart, HelpCircle, Sparkles } from 'lucide-react';
|
||||
import NavbarLayoutFloatingInline from '@/components/navbar/NavbarLayoutFloatingInline';
|
||||
import Link from 'next/link';
|
||||
import { Sparkles, Award, CheckCircle, Heart, Globe, HelpCircle } from 'lucide-react';
|
||||
|
||||
export default function LandingPage() {
|
||||
export default function Home() {
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="bounce-effect"
|
||||
defaultTextAnimation="background-highlight"
|
||||
borderRadius="soft"
|
||||
contentWidth="small"
|
||||
sizing="mediumLargeSizeLargeTitles"
|
||||
background="fluid"
|
||||
cardStyle="glass-depth"
|
||||
primaryButtonStyle="shadow"
|
||||
secondaryButtonStyle="layered"
|
||||
headingFontWeight="semibold"
|
||||
defaultButtonVariant="text-stagger"
|
||||
defaultTextAnimation="entrance-slide"
|
||||
borderRadius="rounded"
|
||||
contentWidth="medium"
|
||||
sizing="medium"
|
||||
background="circleGradient"
|
||||
cardStyle="glass-elevated"
|
||||
primaryButtonStyle="gradient"
|
||||
secondaryButtonStyle="glass"
|
||||
headingFontWeight="normal"
|
||||
>
|
||||
<div id="nav" data-section="nav">
|
||||
<NavbarLayoutFloatingInline
|
||||
brandName="ShrinkArt"
|
||||
navItems={[
|
||||
{ name: "Home", id: "/" },
|
||||
{ name: "Shop", id: "products" },
|
||||
{ name: "About", id: "about" },
|
||||
{ name: "Reviews", id: "testimonials" },
|
||||
{ name: "FAQ", id: "faq" }
|
||||
{ name: "FAQ", id: "faq" },
|
||||
]}
|
||||
button={{ text: "Shop Now", href: "#products" }}
|
||||
button={{
|
||||
text: "Contact", href: "#"}}
|
||||
animateOnLoad={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -53,18 +55,16 @@ export default function LandingPage() {
|
||||
mediaAnimation="slide-up"
|
||||
buttons={[
|
||||
{ text: "Start Shopping", href: "#products" },
|
||||
{ text: "View Collections", href: "#about" }
|
||||
{ text: "View Collections", href: "#about" },
|
||||
]}
|
||||
buttonAnimation="slide-up"
|
||||
testimonials={[
|
||||
{
|
||||
name: "Emma Rodriguez", handle: "Fashion Enthusiast", testimonial: "The quality and creativity are incredible! Each piece is truly unique.", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/smiling-call-center-manager-providing-guidance-intern-addressing-questions_482257-125804.jpg?_wi=1"
|
||||
},
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/smiling-call-center-manager-providing-guidance-intern-addressing-questions_482257-125804.jpg"},
|
||||
{
|
||||
name: "Sarah Chen", handle: "Jewelry Collector", testimonial: "Best handmade jewelry I've purchased. Highly recommend!", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/people-recording-their-house-tour_23-2151139106.jpg?_wi=1"
|
||||
}
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/people-recording-their-house-tour_23-2151139106.jpg"},
|
||||
]}
|
||||
testimonialRotationInterval={5000}
|
||||
useInvertedBackground={false}
|
||||
@@ -86,20 +86,16 @@ export default function LandingPage() {
|
||||
products={[
|
||||
{
|
||||
id: "1", brand: "ShrinkArt", name: "Rainbow Dream Earrings", price: "$24.99", rating: 5,
|
||||
reviewCount: "342", imageSrc: "http://img.b2bpic.net/free-photo/jewelry-from-natural-gems_1398-2320.jpg", imageAlt: "Colorful rainbow shrink plastic earrings"
|
||||
},
|
||||
reviewCount: "342", imageSrc: "http://img.b2bpic.net/free-photo/jewelry-from-natural-gems_1398-2320.jpg", imageAlt: "Colorful rainbow shrink plastic earrings"},
|
||||
{
|
||||
id: "2", brand: "ShrinkArt", name: "Ocean Blue Pendant", price: "$34.99", rating: 5,
|
||||
reviewCount: "189", imageSrc: "http://img.b2bpic.net/free-photo/tasty-cookie-tray-marble-surface_114579-79727.jpg", imageAlt: "Artistic blue resin pendant necklace"
|
||||
},
|
||||
reviewCount: "189", imageSrc: "http://img.b2bpic.net/free-photo/tasty-cookie-tray-marble-surface_114579-79727.jpg", imageAlt: "Artistic blue resin pendant necklace"},
|
||||
{
|
||||
id: "3", brand: "ShrinkArt", name: "Sunset Ring Set", price: "$29.99", rating: 5,
|
||||
reviewCount: "267", imageSrc: "http://img.b2bpic.net/free-photo/emotional-happy-hipster-beautiful-woman-yellow-blouse-blue_285396-1861.jpg", imageAlt: "Vibrant multicolor shrink plastic rings"
|
||||
},
|
||||
reviewCount: "267", imageSrc: "http://img.b2bpic.net/free-photo/emotional-happy-hipster-beautiful-woman-yellow-blouse-blue_285396-1861.jpg", imageAlt: "Vibrant multicolor shrink plastic rings"},
|
||||
{
|
||||
id: "4", brand: "ShrinkArt", name: "Galaxy Bracelet", price: "$32.99", rating: 5,
|
||||
reviewCount: "415", imageSrc: "http://img.b2bpic.net/free-photo/high-angle-girl-wearing-accessories_23-2149645105.jpg", imageAlt: "Handmade purple galaxy polymer bracelet"
|
||||
}
|
||||
reviewCount: "415", imageSrc: "http://img.b2bpic.net/free-photo/high-angle-girl-wearing-accessories_23-2149645105.jpg", imageAlt: "Handmade purple galaxy polymer bracelet"},
|
||||
]}
|
||||
carouselMode="auto"
|
||||
/>
|
||||
@@ -110,12 +106,10 @@ export default function LandingPage() {
|
||||
heading={[
|
||||
{ type: "text", content: "Crafted with passion, designed with" },
|
||||
{ type: "image", src: "http://img.b2bpic.net/free-photo/jewelry-maker-working-alone-atelier_23-2149025951.jpg", alt: "Artisan crafting workspace" },
|
||||
{ type: "text", content: "precision" }
|
||||
{ type: "text", content: "precision" },
|
||||
]}
|
||||
useInvertedBackground={false}
|
||||
buttons={[
|
||||
{ text: "Learn Our Story", href: "#" }
|
||||
]}
|
||||
buttons={[{ text: "Learn Our Story", href: "#" }]}
|
||||
buttonAnimation="blur-reveal"
|
||||
/>
|
||||
</div>
|
||||
@@ -132,16 +126,13 @@ export default function LandingPage() {
|
||||
features={[
|
||||
{
|
||||
id: 1,
|
||||
title: "Premium Quality", description: "Each piece is meticulously crafted using high-quality shrink plastic materials that are durable and vibrant.", imageSrc: "http://img.b2bpic.net/free-vector/pack-four-questionnaire-stickers-vintage-style_23-2147593884.jpg"
|
||||
},
|
||||
title: "Premium Quality", description: "Each piece is meticulously crafted using high-quality shrink plastic materials that are durable and vibrant.", imageSrc: "http://img.b2bpic.net/free-vector/pack-four-questionnaire-stickers-vintage-style_23-2147593884.jpg"},
|
||||
{
|
||||
id: 2,
|
||||
title: "Custom Designs", description: "We offer personalized customization options to create jewelry that perfectly matches your unique style and preferences.", imageSrc: "http://img.b2bpic.net/free-photo/jeweler-s-hands-making-jewellery_23-2150931470.jpg"
|
||||
},
|
||||
title: "Custom Designs", description: "We offer personalized customization options to create jewelry that perfectly matches your unique style and preferences.", imageSrc: "http://img.b2bpic.net/free-photo/jeweler-s-hands-making-jewellery_23-2150931470.jpg"},
|
||||
{
|
||||
id: 3,
|
||||
title: "Fast Shipping", description: "Your orders are packed with care and shipped quickly to ensure you receive your jewelry in perfect condition.", imageSrc: "http://img.b2bpic.net/free-photo/delivery-boxes-watches-with-santa-hat-optimization-delivery-logistics-transport-company_493343-29831.jpg"
|
||||
}
|
||||
title: "Fast Shipping", description: "Your orders are packed with care and shipped quickly to ensure you receive your jewelry in perfect condition.", imageSrc: "http://img.b2bpic.net/free-photo/delivery-boxes-watches-with-santa-hat-optimization-delivery-logistics-transport-company_493343-29831.jpg"},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -154,10 +145,10 @@ export default function LandingPage() {
|
||||
cardAnimation="blur-reveal"
|
||||
useInvertedBackground={false}
|
||||
testimonials={[
|
||||
{ id: "1", name: "Jessica", imageSrc: "http://img.b2bpic.net/free-photo/smiling-call-center-manager-providing-guidance-intern-addressing-questions_482257-125804.jpg?_wi=2" },
|
||||
{ id: "2", name: "Michael", imageSrc: "http://img.b2bpic.net/free-photo/people-recording-their-house-tour_23-2151139106.jpg?_wi=2" },
|
||||
{ id: "1", name: "Jessica", imageSrc: "http://img.b2bpic.net/free-photo/smiling-call-center-manager-providing-guidance-intern-addressing-questions_482257-125804.jpg" },
|
||||
{ id: "2", name: "Michael", imageSrc: "http://img.b2bpic.net/free-photo/people-recording-their-house-tour_23-2151139106.jpg" },
|
||||
{ id: "3", name: "Lisa", imageSrc: "http://img.b2bpic.net/free-photo/woman-showing-ok-sign_23-2148990150.jpg" },
|
||||
{ id: "4", name: "David", imageSrc: "http://img.b2bpic.net/free-photo/pretty-woman-doing-okay-symbol_1187-4123.jpg" }
|
||||
{ id: "4", name: "David", imageSrc: "http://img.b2bpic.net/free-photo/pretty-woman-doing-okay-symbol_1187-4123.jpg" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -171,7 +162,8 @@ export default function LandingPage() {
|
||||
tagAnimation="opacity"
|
||||
textboxLayout="default"
|
||||
useInvertedBackground={true}
|
||||
names={["Fashion Forward Magazine", "Artisan Crafts Weekly", "Jewelry Design Hub", "Creative Makers Collective", "Premium Handmade Directory", "Etsy Sellers Elite", "Independent Artists Network"]}
|
||||
names={[
|
||||
"Fashion Forward Magazine", "Artisan Crafts Weekly", "Jewelry Design Hub", "Creative Makers Collective", "Premium Handmade Directory", "Etsy Sellers Elite", "Independent Artists Network"]}
|
||||
speed={35}
|
||||
showCard={true}
|
||||
/>
|
||||
@@ -193,23 +185,17 @@ export default function LandingPage() {
|
||||
faqsAnimation="blur-reveal"
|
||||
faqs={[
|
||||
{
|
||||
id: "1", title: "What materials do you use?", content: "We use premium quality shrink plastic (also known as shrink film) and UV-resistant resins. All materials are non-toxic and safe to wear. Each piece is sealed with a protective coating to ensure longevity and color vibrancy."
|
||||
},
|
||||
id: "1", title: "What materials do you use?", content: "We use premium quality shrink plastic (also known as shrink film) and UV-resistant resins. All materials are non-toxic and safe to wear. Each piece is sealed with a protective coating to ensure longevity and color vibrancy."},
|
||||
{
|
||||
id: "2", title: "Do you offer custom orders?", content: "Absolutely! We love creating custom pieces. Contact us with your design ideas, color preferences, and specifications. Most custom orders are completed within 7-10 business days. We offer a free design consultation to ensure your vision comes to life."
|
||||
},
|
||||
id: "2", title: "Do you offer custom orders?", content: "Absolutely! We love creating custom pieces. Contact us with your design ideas, color preferences, and specifications. Most custom orders are completed within 7-10 business days. We offer a free design consultation to ensure your vision comes to life."},
|
||||
{
|
||||
id: "3", title: "How long do pieces typically last?", content: "Our jewelry is designed to last for years with proper care. The shrink plastic is durable and the protective coating prevents fading. Avoid exposing pieces to extreme heat, prolonged moisture, or harsh chemicals for optimal longevity."
|
||||
},
|
||||
id: "3", title: "How long do pieces typically last?", content: "Our jewelry is designed to last for years with proper care. The shrink plastic is durable and the protective coating prevents fading. Avoid exposing pieces to extreme heat, prolonged moisture, or harsh chemicals for optimal longevity."},
|
||||
{
|
||||
id: "4", title: "What is your shipping policy?", content: "We offer standard shipping (5-7 business days) and express shipping (2-3 business days). All orders are carefully packaged and insured. We ship worldwide with tracking information provided for every order."
|
||||
},
|
||||
id: "4", title: "What is your shipping policy?", content: "We offer standard shipping (5-7 business days) and express shipping (2-3 business days). All orders are carefully packaged and insured. We ship worldwide with tracking information provided for every order."},
|
||||
{
|
||||
id: "5", title: "Are there returns or exchanges?", content: "We offer a 30-day satisfaction guarantee. If you're not completely happy with your purchase, you can return it for a full refund or exchange. Items must be in original condition with original packaging."
|
||||
},
|
||||
id: "5", title: "Are there returns or exchanges?", content: "We offer a 30-day satisfaction guarantee. If you're not completely happy with your purchase, you can return it for a full refund or exchange. Items must be in original condition with original packaging."},
|
||||
{
|
||||
id: "6", title: "How do I care for my jewelry?", content: "Clean gently with a soft, damp cloth. Avoid soaking in water for extended periods. Store in a cool, dry place away from direct sunlight. Remove jewelry before swimming, showering, or engaging in strenuous activities for best results."
|
||||
}
|
||||
id: "6", title: "How do I care for my jewelry?", content: "Clean gently with a soft, damp cloth. Avoid soaking in water for extended periods. Store in a cool, dry place away from direct sunlight. Remove jewelry before swimming, showering, or engaging in strenuous activities for best results."},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -222,29 +208,29 @@ export default function LandingPage() {
|
||||
{ label: "New Arrivals", href: "#products" },
|
||||
{ label: "Best Sellers", href: "#products" },
|
||||
{ label: "Custom Orders", href: "#" },
|
||||
{ label: "Collections", href: "#about" }
|
||||
]
|
||||
{ label: "Collections", href: "#about" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Company", items: [
|
||||
{ label: "About Us", href: "#about" },
|
||||
{ label: "Our Story", href: "#" },
|
||||
{ label: "Contact", href: "#" },
|
||||
{ label: "Blog", href: "#" }
|
||||
]
|
||||
{ label: "Blog", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Support", items: [
|
||||
{ label: "FAQ", href: "#faq" },
|
||||
{ label: "Shipping Info", href: "#" },
|
||||
{ label: "Returns", href: "#" },
|
||||
{ label: "Care Guide", href: "#" }
|
||||
]
|
||||
}
|
||||
{ label: "Care Guide", href: "#" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
copyrightText="© 2025 ShrinkArt | Handcrafted Jewelry with Passion"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,187 +1,50 @@
|
||||
import { useRef } from "react";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { CardAnimationType, GridVariant } from "../types";
|
||||
import { useDepth3DAnimation } from "./useDepth3DAnimation";
|
||||
import { useEffect, useState } from 'react';
|
||||
import useDepth3DAnimation from './useDepth3DAnimation';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface UseCardAnimationProps {
|
||||
animationType: CardAnimationType | "depth-3d";
|
||||
itemCount: number;
|
||||
isGrid?: boolean;
|
||||
supports3DAnimation?: boolean;
|
||||
gridVariant?: GridVariant;
|
||||
useIndividualTriggers?: boolean;
|
||||
interface CardAnimationConfig {
|
||||
autoPlay?: boolean;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const useCardAnimation = ({
|
||||
animationType,
|
||||
itemCount,
|
||||
isGrid = true,
|
||||
supports3DAnimation = false,
|
||||
gridVariant,
|
||||
useIndividualTriggers = false
|
||||
}: UseCardAnimationProps) => {
|
||||
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const perspectiveRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomContentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Enable 3D effect only when explicitly supported and conditions are met
|
||||
const { isMobile } = useDepth3DAnimation({
|
||||
itemRefs,
|
||||
containerRef,
|
||||
perspectiveRef,
|
||||
isEnabled: animationType === "depth-3d" && isGrid && supports3DAnimation && gridVariant === "uniform-all-items-equal",
|
||||
const useCardAnimation = (config: CardAnimationConfig = {}) => {
|
||||
const { autoPlay = true, duration = 300, delay = 100 } = config;
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const { transform } = useDepth3DAnimation({
|
||||
perspective: 1000,
|
||||
rotateX: currentIndex * 5,
|
||||
rotateY: currentIndex * 2,
|
||||
scale: 1 - currentIndex * 0.02,
|
||||
});
|
||||
|
||||
// Use scale-rotate as fallback when depth-3d conditions aren't met
|
||||
const effectiveAnimationType =
|
||||
animationType === "depth-3d" && (isMobile || !isGrid || gridVariant !== "uniform-all-items-equal")
|
||||
? "scale-rotate"
|
||||
: animationType;
|
||||
useEffect(() => {
|
||||
if (!autoPlay) return;
|
||||
|
||||
useGSAP(() => {
|
||||
if (effectiveAnimationType === "none" || effectiveAnimationType === "depth-3d" || itemRefs.current.length === 0) return;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % 5);
|
||||
}, duration + delay);
|
||||
|
||||
const items = itemRefs.current.filter((el) => el !== null);
|
||||
// Include bottomContent in animation if it exists
|
||||
if (bottomContentRef.current) {
|
||||
items.push(bottomContentRef.current);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoPlay, duration, delay]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (!isAnimating) {
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex((prev) => (prev + 1) % 5);
|
||||
setTimeout(() => setIsAnimating(false), duration);
|
||||
}
|
||||
};
|
||||
|
||||
if (effectiveAnimationType === "opacity") {
|
||||
if (useIndividualTriggers) {
|
||||
items.forEach((item) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 1.25,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 1.25,
|
||||
stagger: 0.15,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (effectiveAnimationType === "slide-up") {
|
||||
items.forEach((item, index) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0, yPercent: 15 },
|
||||
{
|
||||
opacity: 1,
|
||||
yPercent: 0,
|
||||
duration: 1,
|
||||
delay: useIndividualTriggers ? 0 : index * 0.15,
|
||||
ease: "sine",
|
||||
scrollTrigger: {
|
||||
trigger: useIndividualTriggers ? item : items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else if (effectiveAnimationType === "scale-rotate") {
|
||||
if (useIndividualTriggers) {
|
||||
items.forEach((item) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ scaleX: 0, rotate: 10 },
|
||||
{
|
||||
scaleX: 1,
|
||||
rotate: 0,
|
||||
duration: 1,
|
||||
ease: "power3",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ scaleX: 0, rotate: 10 },
|
||||
{
|
||||
scaleX: 1,
|
||||
rotate: 0,
|
||||
duration: 1,
|
||||
stagger: 0.15,
|
||||
ease: "power3",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (effectiveAnimationType === "blur-reveal") {
|
||||
if (useIndividualTriggers) {
|
||||
items.forEach((item) => {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{ opacity: 0, filter: "blur(10px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0, filter: "blur(10px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
duration: 1.2,
|
||||
stagger: 0.15,
|
||||
ease: "power2.out",
|
||||
scrollTrigger: {
|
||||
trigger: items[0],
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const handlePrev = () => {
|
||||
if (!isAnimating) {
|
||||
setIsAnimating(true);
|
||||
setCurrentIndex((prev) => (prev - 1 + 5) % 5);
|
||||
setTimeout(() => setIsAnimating(false), duration);
|
||||
}
|
||||
}, [effectiveAnimationType, itemCount, useIndividualTriggers]);
|
||||
};
|
||||
|
||||
return { itemRefs, containerRef, perspectiveRef, bottomContentRef };
|
||||
return { transform, currentIndex, handleNext, handlePrev, isAnimating };
|
||||
};
|
||||
|
||||
export default useCardAnimation;
|
||||
|
||||
@@ -1,156 +1,75 @@
|
||||
"use client";
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { memo, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Input from "@/components/form/Input";
|
||||
import ProductDetailVariantSelect from "@/components/ecommerce/productDetail/ProductDetailVariantSelect";
|
||||
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
|
||||
import { cls } from "@/lib/utils";
|
||||
import { useProducts } from "@/hooks/useProducts";
|
||||
import ProductCatalogItem from "./ProductCatalogItem";
|
||||
import type { CatalogProduct } from "./ProductCatalogItem";
|
||||
|
||||
interface ProductCatalogProps {
|
||||
layout: "page" | "section";
|
||||
products?: CatalogProduct[];
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
filters?: ProductVariant[];
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
gridClassName?: string;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
searchClassName?: string;
|
||||
filterClassName?: string;
|
||||
toolbarClassName?: string;
|
||||
export interface CatalogProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const ProductCatalog = ({
|
||||
layout,
|
||||
products: productsProp,
|
||||
searchValue = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search products...",
|
||||
filters,
|
||||
emptyMessage = "No products found",
|
||||
className = "",
|
||||
gridClassName = "",
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
searchClassName = "",
|
||||
filterClassName = "",
|
||||
toolbarClassName = "",
|
||||
}: ProductCatalogProps) => {
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
interface ProductCatalogProps {
|
||||
products?: CatalogProduct[];
|
||||
onProductClick?: (product: CatalogProduct) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const handleProductClick = useCallback((productId: string) => {
|
||||
router.push(`/shop/${productId}`);
|
||||
}, [router]);
|
||||
const ProductCatalog: React.FC<ProductCatalogProps> = ({
|
||||
products = [],
|
||||
onProductClick,
|
||||
className = '',
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const products: CatalogProduct[] = useMemo(() => {
|
||||
if (productsProp && productsProp.length > 0) {
|
||||
return productsProp;
|
||||
}
|
||||
const filteredProducts = selectedCategory
|
||||
? products.filter((product) => product.category === selectedCategory)
|
||||
: products;
|
||||
|
||||
if (fetchedProducts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const categories = Array.from(
|
||||
new Set(products.map((product) => product.category))
|
||||
);
|
||||
|
||||
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),
|
||||
}));
|
||||
}, [productsProp, fetchedProducts, handleProductClick]);
|
||||
|
||||
if (isLoading && (!productsProp || productsProp.length === 0)) {
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative w-content-width mx-auto",
|
||||
layout === "page" ? "pt-hero-page-padding pb-20" : "py-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-foreground/50 text-center py-20">
|
||||
Loading products...
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cls(
|
||||
"relative w-content-width mx-auto",
|
||||
layout === "page" ? "pt-hero-page-padding pb-20" : "py-20",
|
||||
className
|
||||
)}
|
||||
return (
|
||||
<div className={`product-catalog ${className}`}>
|
||||
<div className="category-filters">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={selectedCategory === null ? 'active' : ''}
|
||||
>
|
||||
{(onSearchChange || (filters && filters.length > 0)) && (
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col md:flex-row gap-4 md:items-end mb-6",
|
||||
toolbarClassName
|
||||
)}
|
||||
>
|
||||
{onSearchChange && (
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
ariaLabel={searchPlaceholder}
|
||||
className={cls("flex-1 w-full h-9 text-sm", searchClassName)}
|
||||
/>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-4 items-end">
|
||||
{filters.map((filter) => (
|
||||
<ProductDetailVariantSelect
|
||||
key={filter.label}
|
||||
variant={filter}
|
||||
selectClassName={filterClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-foreground/50 text-center py-20">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={cls(
|
||||
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6",
|
||||
gridClassName
|
||||
)}
|
||||
>
|
||||
{products.map((product) => (
|
||||
<ProductCatalogItem
|
||||
key={product.id}
|
||||
product={product}
|
||||
className={cardClassName}
|
||||
imageClassName={imageClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
All Products
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={selectedCategory === category ? 'active' : ''}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="products-grid">
|
||||
{filteredProducts.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="product-card"
|
||||
onClick={() => onProductClick?.(product)}
|
||||
>
|
||||
<img src={product.imageSrc} alt={product.imageAlt} />
|
||||
<h3>{product.name}</h3>
|
||||
<div className="rating">
|
||||
<span>{product.rating}★</span>
|
||||
<span>({product.reviewCount})</span>
|
||||
</div>
|
||||
<p className="price">{product.price}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCatalog.displayName = "ProductCatalog";
|
||||
|
||||
export default memo(ProductCatalog);
|
||||
export default ProductCatalog;
|
||||
|
||||
@@ -1,238 +1,83 @@
|
||||
"use client";
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import CardStack from "@/components/cardStack/CardStack";
|
||||
import ProductImage from "@/components/shared/ProductImage";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { useProducts } from "@/hooks/useProducts";
|
||||
import type { Product } from "@/lib/api/product";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type ProductCardFourGridVariant = Exclude<GridVariant, "timeline" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row">;
|
||||
|
||||
type ProductCard = Product & {
|
||||
variant: string;
|
||||
};
|
||||
export interface ProductCard {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
isFavorited?: boolean;
|
||||
onFavorite?: () => void;
|
||||
}
|
||||
|
||||
interface ProductCardFourProps {
|
||||
products?: ProductCard[];
|
||||
carouselMode?: "auto" | "buttons";
|
||||
gridVariant: ProductCardFourGridVariant;
|
||||
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;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
textBoxTitleClassName?: string;
|
||||
textBoxTitleImageWrapperClassName?: string;
|
||||
textBoxTitleImageClassName?: string;
|
||||
textBoxDescriptionClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
cardPriceClassName?: string;
|
||||
cardVariantClassName?: string;
|
||||
actionButtonClassName?: string;
|
||||
gridClassName?: string;
|
||||
carouselClassName?: string;
|
||||
controlsClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
textBoxTagClassName?: string;
|
||||
textBoxButtonContainerClassName?: string;
|
||||
textBoxButtonClassName?: string;
|
||||
textBoxButtonTextClassName?: string;
|
||||
}
|
||||
|
||||
interface ProductCardItemProps {
|
||||
product: ProductCard;
|
||||
shouldUseLightText: boolean;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
cardPriceClassName?: string;
|
||||
cardVariantClassName?: string;
|
||||
actionButtonClassName?: string;
|
||||
}
|
||||
const ProductCardFour: React.FC<ProductCardFourProps> = ({
|
||||
products = [],
|
||||
title = 'Products',
|
||||
description = 'Browse our collection',
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
|
||||
const ProductCardItem = memo(({
|
||||
product,
|
||||
shouldUseLightText,
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
cardNameClassName = "",
|
||||
cardPriceClassName = "",
|
||||
cardVariantClassName = "",
|
||||
actionButtonClassName = "",
|
||||
}: ProductCardItemProps) => {
|
||||
return (
|
||||
<article
|
||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
||||
onClick={product.onProductClick}
|
||||
role="article"
|
||||
aria-label={`${product.name} - ${product.price}`}
|
||||
>
|
||||
<ProductImage
|
||||
imageSrc={product.imageSrc}
|
||||
imageAlt={product.imageAlt || product.name}
|
||||
isFavorited={product.isFavorited}
|
||||
onFavoriteToggle={product.onFavorite}
|
||||
showActionButton={true}
|
||||
actionButtonAriaLabel={`View ${product.name} details`}
|
||||
imageClassName={imageClassName}
|
||||
actionButtonClassName={actionButtonClassName}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0 flex-1 min-w-0">
|
||||
<h3 className={cls("text-base font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className={cls("text-sm leading-[1.3]", shouldUseLightText ? "text-background/60" : "text-foreground/60", cardVariantClassName)}>
|
||||
{product.variant}
|
||||
</p>
|
||||
</div>
|
||||
<p className={cls("text-base font-medium leading-[1.3] flex-shrink-0", shouldUseLightText ? "text-background" : "text-foreground", cardPriceClassName)}>
|
||||
{product.price}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
ProductCardItem.displayName = "ProductCardItem";
|
||||
|
||||
const ProductCardFour = ({
|
||||
products: productsProp,
|
||||
carouselMode = "buttons",
|
||||
gridVariant,
|
||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
ariaLabel = "Product section",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
textBoxTitleClassName = "",
|
||||
textBoxTitleImageWrapperClassName = "",
|
||||
textBoxTitleImageClassName = "",
|
||||
textBoxDescriptionClassName = "",
|
||||
cardNameClassName = "",
|
||||
cardPriceClassName = "",
|
||||
cardVariantClassName = "",
|
||||
actionButtonClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
textBoxTagClassName = "",
|
||||
textBoxButtonContainerClassName = "",
|
||||
textBoxButtonClassName = "",
|
||||
textBoxButtonTextClassName = "",
|
||||
}: ProductCardFourProps) => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = (isFromApi ? fetchedProducts : productsProp) as ProductCard[];
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const handleProductClick = useCallback((product: ProductCard) => {
|
||||
if (isFromApi) {
|
||||
router.push(`/shop/${product.id}`);
|
||||
const handleFavorite = (productId: string) => {
|
||||
const newFavorites = new Set(favorites);
|
||||
if (newFavorites.has(productId)) {
|
||||
newFavorites.delete(productId);
|
||||
} else {
|
||||
product.onProductClick?.();
|
||||
newFavorites.add(productId);
|
||||
}
|
||||
}, [isFromApi, router]);
|
||||
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<div className="w-content-width mx-auto py-20 text-center">
|
||||
<p className="text-foreground">Loading products...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
setFavorites(newFavorites);
|
||||
};
|
||||
|
||||
return (
|
||||
<CardStack
|
||||
mode={carouselMode}
|
||||
gridVariant={gridVariant}
|
||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
||||
animationType={animationType}
|
||||
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
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}
|
||||
>
|
||||
{products?.map((product, index) => (
|
||||
<ProductCardItem
|
||||
key={`${product.id}-${index}`}
|
||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
||||
shouldUseLightText={shouldUseLightText}
|
||||
cardClassName={cardClassName}
|
||||
imageClassName={imageClassName}
|
||||
cardNameClassName={cardNameClassName}
|
||||
cardPriceClassName={cardPriceClassName}
|
||||
cardVariantClassName={cardVariantClassName}
|
||||
actionButtonClassName={actionButtonClassName}
|
||||
/>
|
||||
))}
|
||||
</CardStack>
|
||||
<div className={`product-card-four ${className}`}>
|
||||
<div className="header">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="products-grid">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="product-item">
|
||||
<div className="image-wrapper">
|
||||
<img src={product.imageSrc} alt={product.imageAlt} />
|
||||
<button
|
||||
className="favorite-btn"
|
||||
onClick={() => handleFavorite(product.id)}
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
fill={favorites.has(product.id) ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="product-info">
|
||||
<h3>{product.name}</h3>
|
||||
<div className="rating">
|
||||
<span className="stars">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className={i < product.rating ? 'filled' : ''}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="count">({product.reviewCount})</span>
|
||||
</div>
|
||||
<p className="price">{product.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCardFour.displayName = "ProductCardFour";
|
||||
|
||||
export default ProductCardFour;
|
||||
|
||||
@@ -1,226 +1,75 @@
|
||||
"use client";
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import CardStack from "@/components/cardStack/CardStack";
|
||||
import ProductImage from "@/components/shared/ProductImage";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { useProducts } from "@/hooks/useProducts";
|
||||
import type { Product } from "@/lib/api/product";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type ProductCardOneGridVariant = Exclude<GridVariant, "timeline">;
|
||||
|
||||
type ProductCard = Product;
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
isFavorited?: boolean;
|
||||
onFavorite?: () => void;
|
||||
}
|
||||
|
||||
interface ProductCardOneProps {
|
||||
products?: ProductCard[];
|
||||
carouselMode?: "auto" | "buttons";
|
||||
gridVariant: ProductCardOneGridVariant;
|
||||
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;
|
||||
imageClassName?: string;
|
||||
textBoxTitleClassName?: string;
|
||||
textBoxTitleImageWrapperClassName?: string;
|
||||
textBoxTitleImageClassName?: string;
|
||||
textBoxDescriptionClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
cardPriceClassName?: string;
|
||||
gridClassName?: string;
|
||||
carouselClassName?: string;
|
||||
controlsClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
textBoxTagClassName?: string;
|
||||
textBoxButtonContainerClassName?: string;
|
||||
textBoxButtonClassName?: string;
|
||||
textBoxButtonTextClassName?: string;
|
||||
products?: Product[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ProductCardItemProps {
|
||||
product: ProductCard;
|
||||
shouldUseLightText: boolean;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
cardPriceClassName?: string;
|
||||
}
|
||||
const ProductCardOne: React.FC<ProductCardOneProps> = ({
|
||||
products = [],
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
|
||||
const ProductCardItem = memo(({
|
||||
product,
|
||||
shouldUseLightText,
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
cardNameClassName = "",
|
||||
cardPriceClassName = "",
|
||||
}: ProductCardItemProps) => {
|
||||
return (
|
||||
<article
|
||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
||||
onClick={product.onProductClick}
|
||||
role="article"
|
||||
aria-label={`${product.name} - ${product.price}`}
|
||||
>
|
||||
<ProductImage
|
||||
imageSrc={product.imageSrc}
|
||||
imageAlt={product.imageAlt || product.name}
|
||||
isFavorited={product.isFavorited}
|
||||
onFavoriteToggle={product.onFavorite}
|
||||
imageClassName={imageClassName}
|
||||
/>
|
||||
|
||||
<div className="relative z-1 flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={cls("text-base font-medium truncate leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className={cls("text-2xl font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardPriceClassName)}>
|
||||
{product.price}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="relative cursor-pointer primary-button h-10 w-auto aspect-square rounded-theme flex items-center justify-center flex-shrink-0"
|
||||
aria-label={`View ${product.name} details`}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUpRight className="h-4/10 text-primary-cta-text transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
ProductCardItem.displayName = "ProductCardItem";
|
||||
|
||||
const ProductCardOne = ({
|
||||
products: productsProp,
|
||||
carouselMode = "buttons",
|
||||
gridVariant,
|
||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
ariaLabel = "Product section",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
textBoxTitleClassName = "",
|
||||
textBoxTitleImageWrapperClassName = "",
|
||||
textBoxTitleImageClassName = "",
|
||||
textBoxDescriptionClassName = "",
|
||||
cardNameClassName = "",
|
||||
cardPriceClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
textBoxTagClassName = "",
|
||||
textBoxButtonContainerClassName = "",
|
||||
textBoxButtonClassName = "",
|
||||
textBoxButtonTextClassName = "",
|
||||
}: ProductCardOneProps) => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = isFromApi ? fetchedProducts : productsProp;
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const handleProductClick = useCallback((product: ProductCard) => {
|
||||
if (isFromApi) {
|
||||
router.push(`/shop/${product.id}`);
|
||||
} else {
|
||||
product.onProductClick?.();
|
||||
}
|
||||
}, [isFromApi, router]);
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<div className="w-content-width mx-auto py-20 text-center">
|
||||
<p className="text-foreground">Loading products...</p>
|
||||
</div>
|
||||
);
|
||||
const handleFavorite = (productId: string) => {
|
||||
const newFavorites = new Set(favorites);
|
||||
if (newFavorites.has(productId)) {
|
||||
newFavorites.delete(productId);
|
||||
} else {
|
||||
newFavorites.add(productId);
|
||||
}
|
||||
setFavorites(newFavorites);
|
||||
};
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardStack
|
||||
mode={carouselMode}
|
||||
gridVariant={gridVariant}
|
||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
||||
animationType={animationType}
|
||||
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
tagAnimation={tagAnimation}
|
||||
buttons={buttons}
|
||||
buttonAnimation={buttonAnimation}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
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}
|
||||
>
|
||||
{products?.map((product, index) => (
|
||||
<ProductCardItem
|
||||
key={`${product.id}-${index}`}
|
||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
||||
shouldUseLightText={shouldUseLightText}
|
||||
cardClassName={cardClassName}
|
||||
imageClassName={imageClassName}
|
||||
cardNameClassName={cardNameClassName}
|
||||
cardPriceClassName={cardPriceClassName}
|
||||
return (
|
||||
<div className={`product-card-one ${className}`}>
|
||||
<div className="products-list">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="product-item">
|
||||
<div className="image-wrapper">
|
||||
<img src={product.imageSrc} alt={product.imageAlt} />
|
||||
<button
|
||||
className="favorite-btn"
|
||||
onClick={() => handleFavorite(product.id)}
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
fill={favorites.has(product.id) ? 'currentColor' : 'none'}
|
||||
/>
|
||||
))}
|
||||
</CardStack>
|
||||
);
|
||||
</button>
|
||||
</div>
|
||||
<div className="product-info">
|
||||
<h3>{product.name}</h3>
|
||||
<div className="rating">
|
||||
<span className="stars">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className={i < product.rating ? 'filled' : ''}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="count">({product.reviewCount})</span>
|
||||
</div>
|
||||
<p className="price">{product.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCardOne.displayName = "ProductCardOne";
|
||||
|
||||
export default ProductCardOne;
|
||||
|
||||
@@ -1,283 +1,83 @@
|
||||
"use client";
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Minus } from "lucide-react";
|
||||
import CardStack from "@/components/cardStack/CardStack";
|
||||
import ProductImage from "@/components/shared/ProductImage";
|
||||
import QuantityButton from "@/components/shared/QuantityButton";
|
||||
import Button from "@/components/button/Button";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { useProducts } from "@/hooks/useProducts";
|
||||
import { getButtonProps } from "@/lib/buttonUtils";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import type { Product } from "@/lib/api/product";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, ButtonAnimationType, GridVariant, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
|
||||
import type { CTAButtonVariant, ButtonPropsForVariant } from "@/components/button/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type ProductCardThreeGridVariant = Exclude<GridVariant, "timeline" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row">;
|
||||
|
||||
type ProductCard = Product & {
|
||||
onQuantityChange?: (quantity: number) => void;
|
||||
initialQuantity?: number;
|
||||
priceButtonProps?: Partial<ButtonPropsForVariant<CTAButtonVariant>>;
|
||||
};
|
||||
export interface ProductCard {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
isFavorited?: boolean;
|
||||
onFavorite?: () => void;
|
||||
}
|
||||
|
||||
interface ProductCardThreeProps {
|
||||
products?: ProductCard[];
|
||||
carouselMode?: "auto" | "buttons";
|
||||
gridVariant: ProductCardThreeGridVariant;
|
||||
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;
|
||||
imageClassName?: string;
|
||||
textBoxTitleClassName?: string;
|
||||
textBoxTitleImageWrapperClassName?: string;
|
||||
textBoxTitleImageClassName?: string;
|
||||
textBoxDescriptionClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
quantityControlsClassName?: string;
|
||||
gridClassName?: string;
|
||||
carouselClassName?: string;
|
||||
controlsClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
textBoxTagClassName?: string;
|
||||
textBoxButtonContainerClassName?: string;
|
||||
textBoxButtonClassName?: string;
|
||||
textBoxButtonTextClassName?: string;
|
||||
products?: ProductCard[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProductCardThree: React.FC<ProductCardThreeProps> = ({
|
||||
products = [],
|
||||
title = 'Products',
|
||||
description = 'Browse our collection',
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
|
||||
interface ProductCardItemProps {
|
||||
product: ProductCard;
|
||||
shouldUseLightText: boolean;
|
||||
isFromApi: boolean;
|
||||
onBuyClick?: (productId: string, quantity: number) => void;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
quantityControlsClassName?: string;
|
||||
}
|
||||
|
||||
const ProductCardItem = memo(({
|
||||
product,
|
||||
shouldUseLightText,
|
||||
isFromApi,
|
||||
onBuyClick,
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
cardNameClassName = "",
|
||||
quantityControlsClassName = "",
|
||||
}: ProductCardItemProps) => {
|
||||
const theme = useTheme();
|
||||
const [quantity, setQuantity] = useState(product.initialQuantity || 1);
|
||||
|
||||
const handleIncrement = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newQuantity = quantity + 1;
|
||||
setQuantity(newQuantity);
|
||||
product.onQuantityChange?.(newQuantity);
|
||||
}, [quantity, product]);
|
||||
|
||||
const handleDecrement = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (quantity > 1) {
|
||||
const newQuantity = quantity - 1;
|
||||
setQuantity(newQuantity);
|
||||
product.onQuantityChange?.(newQuantity);
|
||||
}
|
||||
}, [quantity, product]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isFromApi && onBuyClick) {
|
||||
onBuyClick(product.id, quantity);
|
||||
} else {
|
||||
product.onProductClick?.();
|
||||
}
|
||||
}, [isFromApi, onBuyClick, product, quantity]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
||||
onClick={handleClick}
|
||||
role="article"
|
||||
aria-label={`${product.name} - ${product.price}`}
|
||||
>
|
||||
<ProductImage
|
||||
imageSrc={product.imageSrc}
|
||||
imageAlt={product.imageAlt || product.name}
|
||||
isFavorited={product.isFavorited}
|
||||
onFavoriteToggle={product.onFavorite}
|
||||
imageClassName={imageClassName}
|
||||
/>
|
||||
|
||||
<div className="relative z-1 flex flex-col gap-3">
|
||||
<h3 className={cls("text-xl font-medium leading-[1.15] truncate", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className={cls("flex items-center gap-2", quantityControlsClassName)}>
|
||||
<QuantityButton
|
||||
onClick={handleDecrement}
|
||||
ariaLabel="Decrease quantity"
|
||||
Icon={Minus}
|
||||
/>
|
||||
<span className={cls("text-base font-medium min-w-[2ch] text-center leading-[1]", shouldUseLightText ? "text-background" : "text-foreground")}>
|
||||
{quantity}
|
||||
</span>
|
||||
<QuantityButton
|
||||
onClick={handleIncrement}
|
||||
ariaLabel="Increase quantity"
|
||||
Icon={Plus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
{...getButtonProps(
|
||||
{
|
||||
text: product.price,
|
||||
props: product.priceButtonProps,
|
||||
},
|
||||
0,
|
||||
theme.defaultButtonVariant
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
ProductCardItem.displayName = "ProductCardItem";
|
||||
|
||||
const ProductCardThree = ({
|
||||
products: productsProp,
|
||||
carouselMode = "buttons",
|
||||
gridVariant,
|
||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
ariaLabel = "Product section",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
textBoxTitleClassName = "",
|
||||
textBoxTitleImageWrapperClassName = "",
|
||||
textBoxTitleImageClassName = "",
|
||||
textBoxDescriptionClassName = "",
|
||||
cardNameClassName = "",
|
||||
quantityControlsClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
textBoxTagClassName = "",
|
||||
textBoxButtonContainerClassName = "",
|
||||
textBoxButtonClassName = "",
|
||||
textBoxButtonTextClassName = "",
|
||||
}: ProductCardThreeProps) => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = (isFromApi ? fetchedProducts : productsProp) as ProductCard[];
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const handleProductClick = useCallback((product: ProductCard) => {
|
||||
if (isFromApi) {
|
||||
router.push(`/shop/${product.id}`);
|
||||
} else {
|
||||
product.onProductClick?.();
|
||||
}
|
||||
}, [isFromApi, router]);
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<div className="w-content-width mx-auto py-20 text-center">
|
||||
<p className="text-foreground">Loading products...</p>
|
||||
</div>
|
||||
);
|
||||
const handleFavorite = (productId: string) => {
|
||||
const newFavorites = new Set(favorites);
|
||||
if (newFavorites.has(productId)) {
|
||||
newFavorites.delete(productId);
|
||||
} else {
|
||||
newFavorites.add(productId);
|
||||
}
|
||||
setFavorites(newFavorites);
|
||||
};
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardStack
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
mode={carouselMode}
|
||||
gridVariant={gridVariant}
|
||||
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}
|
||||
>
|
||||
{products?.map((product, index) => (
|
||||
<ProductCardItem
|
||||
key={`${product.id}-${index}`}
|
||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
||||
shouldUseLightText={shouldUseLightText}
|
||||
isFromApi={isFromApi}
|
||||
cardClassName={cardClassName}
|
||||
imageClassName={imageClassName}
|
||||
cardNameClassName={cardNameClassName}
|
||||
quantityControlsClassName={quantityControlsClassName}
|
||||
return (
|
||||
<div className={`product-card-three ${className}`}>
|
||||
<div className="header">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="products-grid">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="product-item">
|
||||
<div className="image-wrapper">
|
||||
<img src={product.imageSrc} alt={product.imageAlt} />
|
||||
<button
|
||||
className="favorite-btn"
|
||||
onClick={() => handleFavorite(product.id)}
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
fill={favorites.has(product.id) ? 'currentColor' : 'none'}
|
||||
/>
|
||||
))}
|
||||
</CardStack>
|
||||
);
|
||||
</button>
|
||||
</div>
|
||||
<div className="product-info">
|
||||
<h3>{product.name}</h3>
|
||||
<div className="rating">
|
||||
<span className="stars">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className={i < product.rating ? 'filled' : ''}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="count">({product.reviewCount})</span>
|
||||
</div>
|
||||
<p className="price">{product.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCardThree.displayName = "ProductCardThree";
|
||||
|
||||
export default ProductCardThree;
|
||||
|
||||
@@ -1,267 +1,109 @@
|
||||
"use client";
|
||||
import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Star } from "lucide-react";
|
||||
import CardStack from "@/components/cardStack/CardStack";
|
||||
import ProductImage from "@/components/shared/ProductImage";
|
||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
||||
import { useProducts } from "@/hooks/useProducts";
|
||||
import type { Product } from "@/lib/api/product";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type ProductCardTwoGridVariant = Exclude<GridVariant, "timeline" | "one-large-right-three-stacked-left" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row" | "one-large-left-three-stacked-right">;
|
||||
|
||||
type ProductCard = Product & {
|
||||
brand: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
};
|
||||
export interface ProductCard {
|
||||
id: string;
|
||||
brand: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
isFavorited?: boolean;
|
||||
onFavorite?: () => void;
|
||||
}
|
||||
|
||||
interface ProductCardTwoProps {
|
||||
products?: ProductCard[];
|
||||
carouselMode?: "auto" | "buttons";
|
||||
gridVariant: ProductCardTwoGridVariant;
|
||||
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;
|
||||
imageClassName?: string;
|
||||
textBoxTitleClassName?: string;
|
||||
textBoxTitleImageWrapperClassName?: string;
|
||||
textBoxTitleImageClassName?: string;
|
||||
textBoxDescriptionClassName?: string;
|
||||
cardBrandClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
cardPriceClassName?: string;
|
||||
cardRatingClassName?: string;
|
||||
actionButtonClassName?: string;
|
||||
gridClassName?: string;
|
||||
carouselClassName?: string;
|
||||
controlsClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
textBoxTagClassName?: string;
|
||||
textBoxButtonContainerClassName?: string;
|
||||
textBoxButtonClassName?: string;
|
||||
textBoxButtonTextClassName?: string;
|
||||
products?: ProductCard[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
tag?: string;
|
||||
tagIcon?: React.ComponentType<{ size?: number; className?: string }>;
|
||||
tagAnimation?: string;
|
||||
textboxLayout?: string;
|
||||
useInvertedBackground?: boolean;
|
||||
gridVariant?: string;
|
||||
animationType?: string;
|
||||
carouselMode?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ProductCardItemProps {
|
||||
product: ProductCard;
|
||||
shouldUseLightText: boolean;
|
||||
cardClassName?: string;
|
||||
imageClassName?: string;
|
||||
cardBrandClassName?: string;
|
||||
cardNameClassName?: string;
|
||||
cardPriceClassName?: string;
|
||||
cardRatingClassName?: string;
|
||||
actionButtonClassName?: string;
|
||||
}
|
||||
const ProductCardTwo: React.FC<ProductCardTwoProps> = ({
|
||||
products = [],
|
||||
title = 'Products',
|
||||
description = 'Browse our collection',
|
||||
tag,
|
||||
tagIcon: TagIcon,
|
||||
tagAnimation,
|
||||
textboxLayout = 'default',
|
||||
useInvertedBackground = false,
|
||||
gridVariant = 'uniform-all-items-equal',
|
||||
animationType = 'none',
|
||||
carouselMode = 'buttons',
|
||||
className = '',
|
||||
}) => {
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
|
||||
const ProductCardItem = memo(({
|
||||
product,
|
||||
shouldUseLightText,
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
cardBrandClassName = "",
|
||||
cardNameClassName = "",
|
||||
cardPriceClassName = "",
|
||||
cardRatingClassName = "",
|
||||
actionButtonClassName = "",
|
||||
}: ProductCardItemProps) => {
|
||||
return (
|
||||
<article
|
||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
||||
onClick={product.onProductClick}
|
||||
role="article"
|
||||
aria-label={`${product.brand} ${product.name} - ${product.price}`}
|
||||
>
|
||||
<ProductImage
|
||||
imageSrc={product.imageSrc}
|
||||
imageAlt={product.imageAlt || `${product.brand} ${product.name}`}
|
||||
isFavorited={product.isFavorited}
|
||||
onFavoriteToggle={product.onFavorite}
|
||||
showActionButton={true}
|
||||
actionButtonAriaLabel={`View ${product.name} details`}
|
||||
imageClassName={imageClassName}
|
||||
actionButtonClassName={actionButtonClassName}
|
||||
/>
|
||||
|
||||
<div className="relative z-1 flex-1 min-w-0 flex flex-col gap-2">
|
||||
<p className={cls("text-sm leading-[1]", shouldUseLightText ? "text-background" : "text-foreground", cardBrandClassName)}>
|
||||
{product.brand}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1" >
|
||||
<h3 className={cls("text-xl font-medium truncate leading-[1.15]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className={cls("flex items-center gap-2", cardRatingClassName)}>
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cls(
|
||||
"h-4 w-auto",
|
||||
i < Math.floor(product.rating)
|
||||
? "text-accent fill-accent"
|
||||
: "text-accent opacity-20"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={cls("text-sm leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground")}>
|
||||
({product.reviewCount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={cls("text-2xl font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardPriceClassName)}>
|
||||
{product.price}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
ProductCardItem.displayName = "ProductCardItem";
|
||||
|
||||
const ProductCardTwo = ({
|
||||
products: productsProp,
|
||||
carouselMode = "buttons",
|
||||
gridVariant,
|
||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
tagAnimation,
|
||||
buttons,
|
||||
buttonAnimation,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
ariaLabel = "Product section",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
imageClassName = "",
|
||||
textBoxTitleClassName = "",
|
||||
textBoxTitleImageWrapperClassName = "",
|
||||
textBoxTitleImageClassName = "",
|
||||
textBoxDescriptionClassName = "",
|
||||
cardBrandClassName = "",
|
||||
cardNameClassName = "",
|
||||
cardPriceClassName = "",
|
||||
cardRatingClassName = "",
|
||||
actionButtonClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
textBoxTagClassName = "",
|
||||
textBoxButtonContainerClassName = "",
|
||||
textBoxButtonClassName = "",
|
||||
textBoxButtonTextClassName = "",
|
||||
}: ProductCardTwoProps) => {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = (fetchedProducts.length > 0 ? fetchedProducts : productsProp) as ProductCard[];
|
||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
||||
|
||||
const handleProductClick = useCallback((product: ProductCard) => {
|
||||
if (isFromApi) {
|
||||
router.push(`/shop/${product.id}`);
|
||||
} else {
|
||||
product.onProductClick?.();
|
||||
}
|
||||
}, [isFromApi, router]);
|
||||
|
||||
const customGridRows = (gridVariant === "bento-grid" || gridVariant === "bento-grid-inverted")
|
||||
? "md:grid-rows-[22rem_22rem] 2xl:grid-rows-[26rem_26rem]"
|
||||
: undefined;
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<div className="w-content-width mx-auto py-20 text-center">
|
||||
<p className="text-foreground">Loading products...</p>
|
||||
</div>
|
||||
);
|
||||
const handleFavorite = (productId: string) => {
|
||||
const newFavorites = new Set(favorites);
|
||||
if (newFavorites.has(productId)) {
|
||||
newFavorites.delete(productId);
|
||||
} else {
|
||||
newFavorites.add(productId);
|
||||
}
|
||||
setFavorites(newFavorites);
|
||||
};
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardStack
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
mode={carouselMode}
|
||||
gridVariant={gridVariant}
|
||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
||||
gridRowsClassName={customGridRows}
|
||||
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}
|
||||
>
|
||||
{products?.map((product, index) => (
|
||||
<ProductCardItem
|
||||
key={`${product.id}-${index}`}
|
||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
||||
shouldUseLightText={shouldUseLightText}
|
||||
cardClassName={cardClassName}
|
||||
imageClassName={imageClassName}
|
||||
cardBrandClassName={cardBrandClassName}
|
||||
cardNameClassName={cardNameClassName}
|
||||
cardPriceClassName={cardPriceClassName}
|
||||
cardRatingClassName={cardRatingClassName}
|
||||
actionButtonClassName={actionButtonClassName}
|
||||
return (
|
||||
<div
|
||||
className={`product-card-two ${useInvertedBackground ? 'inverted' : ''} ${className}`}
|
||||
>
|
||||
<div className="textbox">
|
||||
{tag && (
|
||||
<div className="tag">
|
||||
{TagIcon && <TagIcon size={16} />}
|
||||
<span>{tag}</span>
|
||||
</div>
|
||||
)}
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className={`grid ${gridVariant}`}>
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="product-card">
|
||||
<div className="image-wrapper">
|
||||
<img src={product.imageSrc} alt={product.imageAlt} />
|
||||
<button
|
||||
className="favorite-btn"
|
||||
onClick={() => handleFavorite(product.id)}
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
fill={favorites.has(product.id) ? 'currentColor' : 'none'}
|
||||
/>
|
||||
))}
|
||||
</CardStack>
|
||||
);
|
||||
</button>
|
||||
</div>
|
||||
<div className="product-info">
|
||||
<span className="brand">{product.brand}</span>
|
||||
<h3>{product.name}</h3>
|
||||
<div className="rating">
|
||||
<span className="stars">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span key={i} className={i < product.rating ? 'filled' : ''}>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="count">({product.reviewCount})</span>
|
||||
</div>
|
||||
<p className="price">{product.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProductCardTwo.displayName = "ProductCardTwo";
|
||||
|
||||
export default ProductCardTwo;
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getProductById } from '@/lib/api/product';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Product, fetchProduct } from "@/lib/api/product";
|
||||
|
||||
export function useProduct(productId: string) {
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadProduct() {
|
||||
if (!productId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await fetchProduct(productId);
|
||||
if (isMounted) {
|
||||
setProduct(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to fetch product"));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadProduct();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [productId]);
|
||||
|
||||
return { product, isLoading, error };
|
||||
interface ProductState {
|
||||
product: unknown | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const useProduct = (productId: string) => {
|
||||
const [state, setState] = useState<ProductState>({
|
||||
product: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
|
||||
const fetchProduct = async () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const product = await getProductById(productId);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
product,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch product',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
fetchProduct();
|
||||
}, [productId]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default useProduct;
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getProducts } from '@/lib/api/product';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Product, fetchProducts } from "@/lib/api/product";
|
||||
|
||||
export function useProducts() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const data = await fetchProducts();
|
||||
if (isMounted) {
|
||||
setProducts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to fetch products"));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadProducts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { products, isLoading, error };
|
||||
interface ProductsState {
|
||||
products: unknown[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const useProducts = () => {
|
||||
const [state, setState] = useState<ProductsState>({
|
||||
products: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
const products = await getProducts();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
products,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Failed to fetch products',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default useProducts;
|
||||
|
||||
Reference in New Issue
Block a user