Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ce33a09c | |||
| 2b8f870085 | |||
| 5f9f5136b3 | |||
| f7dfab92f6 | |||
| a1e480e9c2 | |||
| 5cd239b70f | |||
| 61054f6dea | |||
| a47871032d | |||
| 637bfd8222 | |||
| d28296e0ab | |||
| 7905425b1a | |||
| 876d805d49 | |||
| b38d9f4158 | |||
| e3d61b2f11 | |||
| 1150953185 | |||
| 36c32f58bd | |||
| cd2b24ac04 | |||
| e747e323aa | |||
| dc1aa5c51d | |||
| 6d0384a4bb | |||
| 0a56d74b86 | |||
| 79ee098180 | |||
| 819cf506ff | |||
| 0a8a6c2ad1 | |||
| 3f7d960cd2 | |||
| 968d02da4d | |||
| 810a5cfe4d | |||
| 83afb6e942 | |||
| d499bf276d | |||
| 359e805e27 | |||
| e81fc475da | |||
| 72f91f69b2 | |||
| faf90330e9 | |||
| d866ae5db9 | |||
| fde44550c5 | |||
| 93f56494ca | |||
| b06dccf8d3 | |||
| fde80376be | |||
| d7328e33c6 | |||
| 040445f6c6 | |||
| d2a2ebb4df | |||
| 3855ebab4f | |||
| 8796ab757a | |||
| eb54b4255c | |||
| a8c79d3b1b | |||
| 79df96450d | |||
| 269627ee66 | |||
| fd0950d7d2 | |||
| 2f9034aa46 | |||
| d0a9454f6a | |||
| b453d360a0 | |||
| 9e10d16d35 |
@@ -1,20 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Nunito_Sans } from "next/font/google";
|
|
||||||
import { Halant } from "next/font/google";
|
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ServiceWrapper } from "@/components/ServiceWrapper";
|
import { ServiceWrapper } from "@/components/ServiceWrapper";
|
||||||
import Tag from "@/tag/Tag";
|
import Tag from "@/tag/Tag";
|
||||||
|
|
||||||
const nunitoSans = Nunito_Sans({
|
|
||||||
variable: "--font-nunito-sans", subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const halant = Halant({
|
|
||||||
variable: "--font-halant", subsets: ["latin"],
|
|
||||||
weight: ["300", "400", "500", "600", "700"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter", subsets: ["latin"],
|
variable: "--font-inter", subsets: ["latin"],
|
||||||
});
|
});
|
||||||
@@ -22,34 +11,39 @@ const inter = Inter({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Aether DB - AI Database Schema Generator", description: "Convert plain English descriptions into production-ready PostgreSQL schemas in seconds. Get TypeScript types, API definitions, and seed data instantly.", keywords: "database, schema generator, PostgreSQL, TypeScript, AI, automation, backend", metadataBase: new URL("https://aether-db.com"),
|
title: "Aether DB - AI Database Schema Generator", description: "Convert plain English descriptions into production-ready PostgreSQL schemas in seconds. Get TypeScript types, API definitions, and seed data instantly.", keywords: "database, schema generator, PostgreSQL, TypeScript, AI, automation, backend", metadataBase: new URL("https://aether-db.com"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "https://aether-db.com"},
|
canonical: "https://aether-db.com"
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Aether DB - Stop Writing SQL. Start Building.", description: "Convert plain English into production-ready database foundations. Instant PostgreSQL schemas, TypeScript types, API definitions, and more.", url: "https://aether-db.com", siteName: "Aether DB", type: "website", images: [
|
title: "Aether DB - Stop Writing SQL. Start Building.", description: "Convert plain English into production-ready database foundations. Instant PostgreSQL schemas, TypeScript types, API definitions, and more.", url: "https://aether-db.com", siteName: "Aether DB", type: "website", images: [
|
||||||
{
|
{
|
||||||
url: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/a-modern-software-dashboard-interface-sh-1772511923421-1ac2565c.png", alt: "A modern software dashboard interface showing database schema visualization with tables, relationshi"},
|
url: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/a-modern-software-dashboard-interface-sh-1772511923421-1ac2565c.png", alt: "A modern software dashboard interface showing database schema visualization with tables, relationshi"
|
||||||
],
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image", title: "Aether DB - AI Database Schema Generator", description: "Convert plain English into production-ready database schemas instantly.", images: [
|
card: "summary_large_image", title: "Aether DB - AI Database Schema Generator", description: "Convert plain English into production-ready database schemas instantly.", images: [
|
||||||
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/a-modern-software-dashboard-interface-sh-1772511923421-1ac2565c.png"],
|
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/a-modern-software-dashboard-interface-sh-1772511923421-1ac2565c.png"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
|
||||||
|
</head>
|
||||||
<ServiceWrapper>
|
<ServiceWrapper>
|
||||||
<body
|
<body className={`${inter.variable} antialiased`}>
|
||||||
className={`${nunitoSans.variable} ${halant.variable} ${inter.variable} antialiased`}
|
|
||||||
>
|
|
||||||
<Tag />
|
<Tag />
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
@@ -1423,4 +1417,4 @@ export default function RootLayout({
|
|||||||
</ServiceWrapper>
|
</ServiceWrapper>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
512
src/app/page.tsx
512
src/app/page.tsx
@@ -1,17 +1,180 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import NavbarStyleApple from "@/components/navbar/NavbarStyleApple/NavbarStyleApple";
|
import NavbarStyleApple from "@/components/navbar/NavbarStyleApple/NavbarStyleApple";
|
||||||
import HeroCentered from "@/components/sections/hero/HeroCentered";
|
import HeroCentered from "@/components/sections/hero/HeroCentered";
|
||||||
import MetricSplitMediaAbout from "@/components/sections/about/MetricSplitMediaAbout";
|
import MetricSplitMediaAbout from "@/components/sections/about/MetricSplitMediaAbout";
|
||||||
import FeatureCardSixteen from "@/components/sections/feature/FeatureCardSixteen";
|
import { FeatureCardSixteen } from "@/components/sections/feature/FeatureCardSixteen";
|
||||||
import MetricCardSeven from "@/components/sections/metrics/MetricCardSeven";
|
import { MetricCardSeven } from "@/components/sections/metrics/MetricCardSeven";
|
||||||
import TestimonialCardThirteen from "@/components/sections/testimonial/TestimonialCardThirteen";
|
import { TestimonialCardThirteen } from "@/components/sections/testimonial/TestimonialCardThirteen";
|
||||||
import FaqBase from "@/components/sections/faq/FaqBase";
|
import FaqBase from "@/components/sections/faq/FaqBase";
|
||||||
import ContactSplit from "@/components/sections/contact/ContactSplit";
|
import ContactSplit from "@/components/sections/contact/ContactSplit";
|
||||||
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
|
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
|
||||||
|
import { TimelineProcessFlow } from "@/components/cardStack/layouts/timelines/TimelineProcessFlow";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import ScrollTrigger from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
const lightTheme = {
|
||||||
|
"--background": "#ffffff", "--card": "#f9f9f9", "--foreground": "#000000", "--primary-cta": "#000000", "--secondary-cta": "#f9f9f9", "--accent": "#e2e2e2", "--background-accent": "#c4c4c4", "--primary-cta-text": "#ffffff", "--secondary-cta-text": "#000000"
|
||||||
|
};
|
||||||
|
|
||||||
|
const darkTheme = {
|
||||||
|
"--background": "#0a0a0a", "--card": "#1a1a1a", "--foreground": "#ffffff", "--primary-cta": "#ffffff", "--secondary-cta": "#1a1a1a", "--accent": "#737373", "--background-accent": "#737373", "--primary-cta-text": "#0a0a0a", "--secondary-cta-text": "#ffffff"
|
||||||
|
};
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
|
||||||
|
const theme = isDarkMode ? darkTheme : lightTheme;
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setIsDarkMode(!isDarkMode);
|
||||||
|
Object.entries(theme).forEach(([key, value]) => {
|
||||||
|
document.documentElement.style.setProperty(key, value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Apply initial theme
|
||||||
|
Object.entries(theme).forEach(([key, value]) => {
|
||||||
|
document.documentElement.style.setProperty(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize GSAP animations
|
||||||
|
const initGsapAnimations = () => {
|
||||||
|
// Stagger animation for section elements
|
||||||
|
const sections = document.querySelectorAll('[data-section]');
|
||||||
|
sections.forEach((section) => {
|
||||||
|
const elements = section.querySelectorAll('h1, h2, h3, p, button, [data-animate]');
|
||||||
|
elements.forEach((el, index) => {
|
||||||
|
gsap.set(el, { opacity: 0, y: 20 });
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: el,
|
||||||
|
onEnter: () => {
|
||||||
|
gsap.to(el, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: index * 0.05,
|
||||||
|
ease: "power3.out"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hero section parallax
|
||||||
|
const hero = document.querySelector('#hero');
|
||||||
|
if (hero) {
|
||||||
|
const heroContent = hero.querySelector('[data-animate]');
|
||||||
|
if (heroContent) {
|
||||||
|
gsap.to(heroContent, {
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: hero,
|
||||||
|
start: "top center", end: "bottom center", scrub: 1
|
||||||
|
},
|
||||||
|
y: -50,
|
||||||
|
opacity: 0.8
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card scale animation on scroll
|
||||||
|
const cards = document.querySelectorAll('[class*="card"], [class*="Card"]');
|
||||||
|
cards.forEach((card) => {
|
||||||
|
gsap.set(card, { scale: 0.95, opacity: 0 });
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: card,
|
||||||
|
onEnter: () => {
|
||||||
|
gsap.to(card, {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: "back.out"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Floating animation for images
|
||||||
|
const images = document.querySelectorAll('img[data-animate], [class*="image"] img');
|
||||||
|
images.forEach((img) => {
|
||||||
|
gsap.to(img, {
|
||||||
|
y: -10,
|
||||||
|
duration: 3,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: "sine.inOut"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Text reveal animation
|
||||||
|
const textElements = document.querySelectorAll('h1, h2, h3');
|
||||||
|
textElements.forEach((text) => {
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: text,
|
||||||
|
onEnter: () => {
|
||||||
|
gsap.to(text, {
|
||||||
|
backgroundPosition: "200% center", duration: 1.5,
|
||||||
|
ease: "power2.out"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Button hover glow effect
|
||||||
|
const buttons = document.querySelectorAll('button');
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
btn.addEventListener('mouseenter', () => {
|
||||||
|
gsap.to(btn, {
|
||||||
|
boxShadow: "0 0 20px rgba(0, 0, 0, 0.3)", duration: 0.3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
btn.addEventListener('mouseleave', () => {
|
||||||
|
gsap.to(btn, {
|
||||||
|
boxShadow: "0 0 0px rgba(0, 0, 0, 0)", duration: 0.3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll-triggered counter animations
|
||||||
|
const counters = document.querySelectorAll('[data-count]');
|
||||||
|
counters.forEach((counter) => {
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: counter,
|
||||||
|
onEnter: () => {
|
||||||
|
const target = parseInt(counter.getAttribute('data-count')) || 0;
|
||||||
|
gsap.to(counter, {
|
||||||
|
textContent: target,
|
||||||
|
duration: 2,
|
||||||
|
snap: { textContent: 1 },
|
||||||
|
ease: "power2.out"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for DOM to be fully loaded
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initGsapAnimations);
|
||||||
|
} else {
|
||||||
|
initGsapAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ScrollTrigger.getAll().forEach(trigger => trigger.kill());
|
||||||
|
};
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
defaultButtonVariant="text-stagger"
|
defaultButtonVariant="text-stagger"
|
||||||
@@ -26,16 +189,29 @@ export default function LandingPage() {
|
|||||||
headingFontWeight="medium"
|
headingFontWeight="medium"
|
||||||
>
|
>
|
||||||
<div id="nav" data-section="nav">
|
<div id="nav" data-section="nav">
|
||||||
<NavbarStyleApple
|
<div className="flex items-center justify-between p-4">
|
||||||
brandName="Aether DB"
|
<NavbarStyleApple
|
||||||
navItems={[
|
brandName="Aether DB"
|
||||||
{ name: "Features", id: "features" },
|
navItems={[
|
||||||
{ name: "How It Works", id: "about" },
|
{ name: "What is Aether DB", id: "what-is" },
|
||||||
{ name: "Pricing", id: "pricing" },
|
{ name: "How It Works", id: "process" },
|
||||||
{ name: "FAQ", id: "faq" },
|
{ name: "Features", id: "features" },
|
||||||
{ name: "Contact", id: "contact" },
|
{ name: "FAQ", id: "faq" },
|
||||||
]}
|
{ name: "Contact", id: "contact" }
|
||||||
/>
|
]}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="ml-4 p-2 rounded-lg hover:bg-accent transition-colors"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{isDarkMode ? (
|
||||||
|
<Sun className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="hero" data-section="hero">
|
<div id="hero" data-section="hero">
|
||||||
@@ -45,31 +221,36 @@ export default function LandingPage() {
|
|||||||
background={{ variant: "animated-grid" }}
|
background={{ variant: "animated-grid" }}
|
||||||
avatars={[
|
avatars={[
|
||||||
{
|
{
|
||||||
src: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-confident-sof-1772511922535-4e334974.png", alt: "User 1"},
|
src: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-confident-sof-1772511922535-4e334974.png", alt: "User 1"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-startup-found-1772511922841-7e2c6104.png", alt: "User 2"},
|
src: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-startup-found-1772511922841-7e2c6104.png", alt: "User 2"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-technical-lea-1772511921950-aa22b771.png", alt: "User 3"},
|
src: "https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-technical-lea-1772511921950-aa22b771.png", alt: "User 3"
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
avatarText="Trusted by 500+ developers"
|
avatarText="Trusted by 500+ developers"
|
||||||
buttons={[
|
buttons={[
|
||||||
{ text: "Try it free", href: "#contact" },
|
{ text: "Try it free", href: "#contact" },
|
||||||
{ text: "See demo", href: "#about" },
|
{ text: "See demo", href: "#process" }
|
||||||
]}
|
]}
|
||||||
buttonAnimation="slide-up"
|
buttonAnimation="slide-up"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="about" data-section="about">
|
<div id="what-is" data-section="what-is">
|
||||||
<MetricSplitMediaAbout
|
<MetricSplitMediaAbout
|
||||||
tag="How It Works"
|
tag="What is Aether DB?"
|
||||||
title="Describe Once, Generate Everything"
|
title="Enterprise-Grade Database Infrastructure in Minutes"
|
||||||
description="Type a description of your application—teams, projects, tasks, comments, billing—and Aether DB instantly generates PostgreSQL schemas with proper indexes, constraints, and RLS policies. Plus Zod schemas, TypeScript types, API definitions, seed data, and architectural documentation."
|
description="Aether DB is an AI-powered database schema generator that transforms natural language descriptions into production-ready PostgreSQL databases. Designed for award-winning teams who demand excellence, it eliminates manual schema design, reduces development time by 90%, and ensures enterprise-level security and performance from day one."
|
||||||
metrics={[
|
metrics={[
|
||||||
{
|
{
|
||||||
value: "⚡ Seconds", title: "From prompt to production-ready code"},
|
value: "90%", title: "Faster development cycles"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "100%", title: "Type-safe across your entire stack"},
|
value: "100%", title: "Type-safe across your stack"
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/an-illustration-showing-the-transformati-1772511922816-9b8b2394.png"
|
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/an-illustration-showing-the-transformati-1772511922816-9b8b2394.png"
|
||||||
imageAlt="Transformation from manual to automated database setup"
|
imageAlt="Transformation from manual to automated database setup"
|
||||||
@@ -79,6 +260,215 @@ export default function LandingPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="process" data-section="process">
|
||||||
|
<TimelineProcessFlow
|
||||||
|
title="5-Step Process: From Concept to Production"
|
||||||
|
description="Experience how Aether DB transforms your database vision into reality"
|
||||||
|
tag="Process"
|
||||||
|
textboxLayout="default"
|
||||||
|
animationType="slide-up"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "1", reverse: false,
|
||||||
|
media: (
|
||||||
|
<svg
|
||||||
|
className="w-full h-full"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
.pulse-circle {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<circle cx="100" cy="100" r="80" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<circle cx="100" cy="100" r="60" className="pulse-circle" fill="currentColor" opacity="0.3" />
|
||||||
|
<text x="100" y="105" textAnchor="middle" fontSize="14" fontWeight="bold" fill="currentColor">
|
||||||
|
Describe
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-bold mb-2">Step 1: Describe Your App</h3>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
Simply tell Aether DB about your application in plain English. Describe your entities, relationships, and business logic as naturally as you would to a colleague.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2", reverse: true,
|
||||||
|
media: (
|
||||||
|
<svg
|
||||||
|
className="w-full h-full"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes slide {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
50% { transform: translateX(20px); }
|
||||||
|
}
|
||||||
|
.slide-arrow {
|
||||||
|
animation: slide 2s infinite;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect x="20" y="70" width="60" height="60" fill="currentColor" opacity="0.3" rx="4" />
|
||||||
|
<path className="slide-arrow" d="M 100 100 L 130 100" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<polygon points="130,100 120,95 120,105" fill="currentColor" />
|
||||||
|
<rect x="140" y="70" width="40" height="60" fill="currentColor" opacity="0.5" rx="4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-bold mb-2">Step 2: AI Analysis</h3>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
Our advanced AI engine analyzes your description, identifies all entities, relationships, and constraints, then generates an optimized schema plan.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3", reverse: false,
|
||||||
|
media: (
|
||||||
|
<svg
|
||||||
|
className="w-full h-full"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes rotate {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.rotate-gear {
|
||||||
|
animation: rotate 4s linear infinite;
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<circle cx="100" cy="100" r="30" fill="none" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<circle cx="100" cy="100" r="15" fill="currentColor" />
|
||||||
|
<circle
|
||||||
|
className="rotate-gear"
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="70"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-bold mb-2">Step 3: Schema Generation</h3>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
Aether DB generates production-ready PostgreSQL schemas with proper indexes, constraints, and row-level security policies automatically.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4", reverse: true,
|
||||||
|
media: (
|
||||||
|
<svg
|
||||||
|
className="w-full h-full"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-20px); }
|
||||||
|
}
|
||||||
|
.bounce-box {
|
||||||
|
animation: bounce 1.5s infinite;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect className="bounce-box" x="60" y="60" width="40" height="40" fill="currentColor" opacity="0.4" rx="4" />
|
||||||
|
<rect x="110" y="90" width="60" height="80" fill="currentColor" opacity="0.2" rx="4" />
|
||||||
|
<text x="140" y="140" textAnchor="middle" fontSize="12" fontWeight="bold" fill="currentColor">
|
||||||
|
Types
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-bold mb-2">Step 4: Complete Code Generation</h3>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
Get Zod schemas, TypeScript types, API endpoint definitions, and seed data—all perfectly synchronized with your database schema.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5", reverse: false,
|
||||||
|
media: (
|
||||||
|
<svg
|
||||||
|
className="w-full h-full"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes checkmark {
|
||||||
|
0% { strokeDashoffset: 100; }
|
||||||
|
100% { strokeDashoffset: 0; }
|
||||||
|
}
|
||||||
|
.checkmark {
|
||||||
|
animation: checkmark 1s ease forwards;
|
||||||
|
stroke-dasharray: 100;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<circle cx="100" cy="100" r="70" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<path
|
||||||
|
className="checkmark"
|
||||||
|
d="M 70 100 L 90 120 L 130 70"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-bold mb-2">Step 5: Deploy & Scale</h3>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
Your production-ready infrastructure is complete. Deploy with confidence knowing your database is secure, optimized, and enterprise-ready.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="features" data-section="features">
|
<div id="features" data-section="features">
|
||||||
<FeatureCardSixteen
|
<FeatureCardSixteen
|
||||||
tag="Traditional vs Aether DB"
|
tag="Traditional vs Aether DB"
|
||||||
@@ -86,11 +476,13 @@ export default function LandingPage() {
|
|||||||
description="Stop spending days planning architecture, writing migrations, and syncing types across your codebase."
|
description="Stop spending days planning architecture, writing migrations, and syncing types across your codebase."
|
||||||
negativeCard={{
|
negativeCard={{
|
||||||
items: [
|
items: [
|
||||||
"Hours planning database schema", "Writing and debugging SQL migrations", "Manually creating TypeScript types", "Syncing types across frontend and backend", "Managing relational integrity by hand", "Setting up RLS policies manually"],
|
"Hours planning database schema", "Writing and debugging SQL migrations", "Manually creating TypeScript types", "Syncing types across frontend and backend", "Managing relational integrity by hand", "Setting up RLS policies manually"
|
||||||
|
]
|
||||||
}}
|
}}
|
||||||
positiveCard={{
|
positiveCard={{
|
||||||
items: [
|
items: [
|
||||||
"Instant schema generation", "Zero-migration SQL setup", "Auto-generated TypeScript types", "Perfect type sync everywhere", "Automatic constraint validation", "Built-in security policies"],
|
"Instant schema generation", "Zero-migration SQL setup", "Auto-generated TypeScript types", "Perfect type sync everywhere", "Automatic constraint validation", "Built-in security policies"
|
||||||
|
]
|
||||||
}}
|
}}
|
||||||
animationType="slide-up"
|
animationType="slide-up"
|
||||||
textboxLayout="default"
|
textboxLayout="default"
|
||||||
@@ -111,16 +503,19 @@ export default function LandingPage() {
|
|||||||
metrics={[
|
metrics={[
|
||||||
{
|
{
|
||||||
id: "1", value: "10x", title: "Faster development", items: [
|
id: "1", value: "10x", title: "Faster development", items: [
|
||||||
"Database setup in minutes", "Reduce development cycles", "Ship features faster"],
|
"Database setup in minutes", "Reduce development cycles", "Ship features faster"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2", value: "0", title: "Type mismatches", items: [
|
id: "2", value: "0", title: "Type mismatches", items: [
|
||||||
"Auto-generated consistency", "End-to-end type safety", "Catch errors early"],
|
"Auto-generated consistency", "End-to-end type safety", "Catch errors early"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3", value: "100%", title: "Schema coverage", items: [
|
id: "3", value: "100%", title: "Schema coverage", items: [
|
||||||
"Complete documentation", "All relationships mapped", "Never miss a constraint"],
|
"Complete documentation", "All relationships mapped", "Never miss a constraint"
|
||||||
},
|
]
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,22 +534,26 @@ export default function LandingPage() {
|
|||||||
id: "1", name: "Sarah Chen", handle: "@sarahchen", testimonial:
|
id: "1", name: "Sarah Chen", handle: "@sarahchen", testimonial:
|
||||||
"Aether DB cut our database setup time from 2 days to 10 minutes. The generated TypeScript types alone saved us countless hours of debugging.", rating: 5,
|
"Aether DB cut our database setup time from 2 days to 10 minutes. The generated TypeScript types alone saved us countless hours of debugging.", rating: 5,
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-confident-sof-1772511922535-4e334974.png", imageAlt: "Sarah Chen"},
|
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-confident-sof-1772511922535-4e334974.png", imageAlt: "Sarah Chen"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "2", name: "Marcus Rodriguez", handle: "@mrodriguez", testimonial:
|
id: "2", name: "Marcus Rodriguez", handle: "@mrodriguez", testimonial:
|
||||||
"Finally, a tool that understands what developers actually need. No more manual schema creation, no more type mismatches. Just describe what you want.", rating: 5,
|
"Finally, a tool that understands what developers actually need. No more manual schema creation, no more type mismatches. Just describe what you want.", rating: 5,
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-startup-found-1772511922841-7e2c6104.png", imageAlt: "Marcus Rodriguez"},
|
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-startup-found-1772511922841-7e2c6104.png", imageAlt: "Marcus Rodriguez"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "3", name: "Emma Thompson", handle: "@emmathompson", testimonial:
|
id: "3", name: "Emma Thompson", handle: "@emmathompson", testimonial:
|
||||||
"The RLS policies and security constraints are production-ready out of the box. This is enterprise-grade infrastructure in minutes.", rating: 5,
|
"The RLS policies and security constraints are production-ready out of the box. This is enterprise-grade infrastructure in minutes.", rating: 5,
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-technical-lea-1772511921950-aa22b771.png", imageAlt: "Emma Thompson"},
|
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-technical-lea-1772511921950-aa22b771.png", imageAlt: "Emma Thompson"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "4", name: "Alex Kumar", handle: "@alexkumar", testimonial:
|
id: "4", name: "Alex Kumar", handle: "@alexkumar", testimonial:
|
||||||
"We went from prototyping to production faster than ever. Aether DB handles the boring database stuff so we can focus on features.", rating: 5,
|
"We went from prototyping to production faster than ever. Aether DB handles the boring database stuff so we can focus on features.", rating: 5,
|
||||||
imageSrc:
|
imageSrc:
|
||||||
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-product-manag-1772511922206-877ffab8.png", imageAlt: "Alex Kumar"},
|
"https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/professional-headshot-of-a-product-manag-1772511922206-877ffab8.png", imageAlt: "Alex Kumar"
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,22 +570,28 @@ export default function LandingPage() {
|
|||||||
faqs={[
|
faqs={[
|
||||||
{
|
{
|
||||||
id: "1", title: "How does Aether DB generate database schemas?", content:
|
id: "1", title: "How does Aether DB generate database schemas?", content:
|
||||||
"Simply describe your application in plain English. Our AI understands your requirements and generates optimized PostgreSQL schemas with proper indexes, constraints, foreign keys, and RLS policies. You get production-ready code instantly."},
|
"Simply describe your application in plain English. Our AI understands your requirements and generates optimized PostgreSQL schemas with proper indexes, constraints, foreign keys, and RLS policies. You get production-ready code instantly."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "2", title: "What exactly do I get when I use Aether DB?", content:
|
id: "2", title: "What exactly do I get when I use Aether DB?", content:
|
||||||
"You receive: PostgreSQL schema with indexes and constraints, Zod schemas for validation, TypeScript types for your entire database, RESTful API endpoint definitions with typed request/response bodies, realistic seed data with relational integrity, and an AI explanation of every architectural decision."},
|
"You receive: PostgreSQL schema with indexes and constraints, Zod schemas for validation, TypeScript types for your entire database, RESTful API endpoint definitions with typed request/response bodies, realistic seed data with relational integrity, and an AI explanation of every architectural decision."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "3", title: "Is the generated code production-ready?", content:
|
id: "3", title: "Is the generated code production-ready?", content:
|
||||||
"Yes. All generated schemas include proper foreign key relationships, unique constraints, check constraints, row-level security policies, and performance indexes. The code is audited and optimized for production environments."},
|
"Yes. All generated schemas include proper foreign key relationships, unique constraints, check constraints, row-level security policies, and performance indexes. The code is audited and optimized for production environments."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "4", title: "Can I customize the generated schema?", content:
|
id: "4", title: "Can I customize the generated schema?", content:
|
||||||
"Absolutely. The generated code is yours to modify. You can edit schemas, adjust constraints, add custom columns, or refine RLS policies. Aether DB gives you the foundation—you maintain full control."},
|
"Absolutely. The generated code is yours to modify. You can edit schemas, adjust constraints, add custom columns, or refine RLS policies. Aether DB gives you the foundation—you maintain full control."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "5", title: "What databases are supported?", content:
|
id: "5", title: "What databases are supported?", content:
|
||||||
"Currently, Aether DB generates PostgreSQL schemas. MySQL and other database support is coming soon."},
|
"Currently, Aether DB generates PostgreSQL schemas. MySQL and other database support is coming soon."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "6", title: "Do you store my project descriptions?", content:
|
id: "6", title: "Do you store my project descriptions?", content:
|
||||||
"We take privacy seriously. Your project descriptions are processed but not stored in our system. Enterprise users can opt for on-premise deployment."},
|
"We take privacy seriously. Your project descriptions are processed but not stored in our system. Enterprise users can opt for on-premise deployment."
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +600,7 @@ export default function LandingPage() {
|
|||||||
<ContactSplit
|
<ContactSplit
|
||||||
tag="Get Started"
|
tag="Get Started"
|
||||||
title="Start Building Faster Today"
|
title="Start Building Faster Today"
|
||||||
description="Join hundreds of developers who are shipping database-backed applications in record time. Sign up for free and generate your first schema in minutes."
|
description="Sign up for free and generate your first production-ready schema in under 2 minutes."
|
||||||
background={{ variant: "animated-grid" }}
|
background={{ variant: "animated-grid" }}
|
||||||
useInvertedBackground={false}
|
useInvertedBackground={false}
|
||||||
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/an-illustration-of-a-person-working-on-a-1772511922490-57b835bf.png"
|
imageSrc="https://webuild-dev.s3.eu-north-1.amazonaws.com/users/user_3AQ53gfKdS0YSH1q5OGpM06AnUi/an-illustration-of-a-person-working-on-a-1772511922490-57b835bf.png"
|
||||||
@@ -214,36 +619,37 @@ export default function LandingPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ label: "Features", href: "#features" },
|
{ label: "What is Aether DB", href: "#what-is" },
|
||||||
{ label: "How It Works", href: "#about" },
|
{ label: "How It Works", href: "#process" },
|
||||||
{ label: "Pricing", href: "#metrics" },
|
{ label: "Features", href: "#features" }
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Documentation", href: "https://docs.aether-db.com"},
|
label: "Documentation", href: "https://docs.aether-db.com"
|
||||||
|
},
|
||||||
{ label: "API Reference", href: "https://docs.aether-db.com/api" },
|
{ label: "API Reference", href: "https://docs.aether-db.com/api" },
|
||||||
{ label: "GitHub", href: "https://github.com/aether-db" },
|
{ label: "GitHub", href: "https://github.com/aether-db" }
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ label: "Blog", href: "https://blog.aether-db.com" },
|
{ label: "Blog", href: "https://blog.aether-db.com" },
|
||||||
{ label: "Twitter", href: "https://twitter.com/aether_db" },
|
{ label: "Twitter", href: "https://twitter.com/aether_db" },
|
||||||
{ label: "Discord", href: "https://discord.gg/aether-db" },
|
{ label: "Discord", href: "https://discord.gg/aether-db" }
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ label: "Privacy Policy", href: "#" },
|
{ label: "Privacy Policy", href: "#" },
|
||||||
{ label: "Terms of Service", href: "#" },
|
{ label: "Terms of Service", href: "#" },
|
||||||
{ label: "Contact", href: "#contact" },
|
{ label: "Contact", href: "#contact" }
|
||||||
],
|
]
|
||||||
},
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,23 @@
|
|||||||
/* Base units */
|
/* Base units */
|
||||||
/* --vw is set by ThemeProvider */
|
/* --vw is set by ThemeProvider */
|
||||||
|
|
||||||
/* --background: #f7f6f7;;
|
/* --background: #ffffff;;
|
||||||
--card: #ffffff;;
|
--card: #f9f9f9;;
|
||||||
--foreground: #250c0d;;
|
--foreground: #000000;;
|
||||||
--primary-cta: #b82b40;;
|
--primary-cta: #000000;;
|
||||||
--secondary-cta: #ffffff;;
|
--secondary-cta: #f9f9f9;;
|
||||||
--accent: #b90941;;
|
--accent: #e2e2e2;;
|
||||||
--background-accent: #e8a8b6;; */
|
--background-accent: #c4c4c4;; */
|
||||||
|
|
||||||
--background: #f7f6f7;;
|
--background: #ffffff;;
|
||||||
--card: #ffffff;;
|
--card: #f9f9f9;;
|
||||||
--foreground: #250c0d;;
|
--foreground: #000000;;
|
||||||
--primary-cta: #b82b40;;
|
--primary-cta: #000000;;
|
||||||
--primary-cta-text: #f7f6f7;;
|
--primary-cta-text: #ffffff;;
|
||||||
--secondary-cta: #ffffff;;
|
--secondary-cta: #f9f9f9;;
|
||||||
--secondary-cta-text: #250c0d;;
|
--secondary-cta-text: #000000;;
|
||||||
--accent: #b90941;;
|
--accent: #e2e2e2;;
|
||||||
--background-accent: #e8a8b6;;
|
--background-accent: #c4c4c4;;
|
||||||
|
|
||||||
/* text sizing - set by ThemeProvider */
|
/* text sizing - set by ThemeProvider */
|
||||||
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
||||||
|
|||||||
@@ -1,229 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, Children } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { CardStackProps } from "./types";
|
import { useCardAnimation } from "./hooks/useCardAnimation";
|
||||||
import GridLayout from "./layouts/grid/GridLayout";
|
|
||||||
import AutoCarousel from "./layouts/carousels/AutoCarousel";
|
|
||||||
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
|
|
||||||
import TimelineBase from "./layouts/timelines/TimelineBase";
|
|
||||||
import { gridConfigs } from "./layouts/grid/gridConfigs";
|
|
||||||
|
|
||||||
const CardStack = ({
|
export interface CardStackItem {
|
||||||
children,
|
id: string;
|
||||||
mode = "buttons",
|
content: React.ReactNode;
|
||||||
gridVariant = "uniform-all-items-equal",
|
}
|
||||||
uniformGridCustomHeightClasses,
|
|
||||||
gridRowsClassName,
|
|
||||||
itemHeightClassesOverride,
|
|
||||||
animationType,
|
|
||||||
supports3DAnimation = false,
|
|
||||||
title,
|
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout = "default",
|
|
||||||
useInvertedBackground,
|
|
||||||
carouselThreshold = 5,
|
|
||||||
bottomContent,
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
carouselItemClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
titleClassName = "",
|
|
||||||
titleImageWrapperClassName = "",
|
|
||||||
titleImageClassName = "",
|
|
||||||
descriptionClassName = "",
|
|
||||||
tagClassName = "",
|
|
||||||
buttonContainerClassName = "",
|
|
||||||
buttonClassName = "",
|
|
||||||
buttonTextClassName = "",
|
|
||||||
ariaLabel = "Card stack",
|
|
||||||
}: CardStackProps) => {
|
|
||||||
const childrenArray = Children.toArray(children);
|
|
||||||
const itemCount = childrenArray.length;
|
|
||||||
|
|
||||||
// Check if the current grid config has gridRows defined
|
export interface CardStackProps {
|
||||||
const gridConfig = gridConfigs[gridVariant]?.[itemCount];
|
items: CardStackItem[];
|
||||||
const hasFixedGridRows = gridConfig && 'gridRows' in gridConfig && gridConfig.gridRows;
|
animationType?: "opacity" | "none" | "slide-up" | "blur-reveal";
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
cardClassName?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// If grid has fixed row heights and we have uniformGridCustomHeightClasses,
|
const CardStack = React.forwardRef<HTMLDivElement, CardStackProps>(
|
||||||
// we need to use min-h-0 on md+ to prevent conflicts
|
(
|
||||||
let adjustedHeightClasses = uniformGridCustomHeightClasses;
|
{
|
||||||
if (hasFixedGridRows && uniformGridCustomHeightClasses) {
|
items,
|
||||||
// Extract the mobile min-height and add md:min-h-0
|
animationType = "opacity", className = "", containerClassName = "", cardClassName = "", ariaLabel = "Card stack"},
|
||||||
const mobileMinHeight = uniformGridCustomHeightClasses.split(' ')[0];
|
ref
|
||||||
adjustedHeightClasses = `${mobileMinHeight} md:min-h-0`;
|
) => {
|
||||||
}
|
const internalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resolvedRef = ref || internalRef;
|
||||||
// Timeline layout for zigzag pattern (works best with 3-6 items)
|
|
||||||
if (gridVariant === "timeline" && itemCount >= 3 && itemCount <= 6) {
|
|
||||||
// Convert depth-3d to scale-rotate for timeline (doesn't support 3D)
|
|
||||||
const timelineAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TimelineBase
|
|
||||||
variant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
|
||||||
animationType={timelineAnimationType}
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</TimelineBase>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use grid for items below threshold, carousel for items at or above threshold
|
|
||||||
// Timeline with 7+ items will also use carousel
|
|
||||||
const useCarousel = itemCount >= carouselThreshold || (gridVariant === "timeline" && itemCount > 6);
|
|
||||||
|
|
||||||
// Grid layout for 1-4 items
|
|
||||||
if (!useCarousel) {
|
|
||||||
return (
|
|
||||||
<GridLayout
|
|
||||||
itemCount={itemCount}
|
|
||||||
gridVariant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
|
||||||
gridRowsClassName={gridRowsClassName}
|
|
||||||
itemHeightClassesOverride={itemHeightClassesOverride}
|
|
||||||
animationType={animationType}
|
|
||||||
supports3DAnimation={supports3DAnimation}
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
bottomContent={bottomContent}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</GridLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-scroll carousel for 5+ items
|
|
||||||
if (mode === "auto") {
|
|
||||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
|
||||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AutoCarousel
|
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
|
||||||
animationType={carouselAnimationType}
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
bottomContent={bottomContent}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</AutoCarousel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button-controlled carousel for 5+ items
|
|
||||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
|
||||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonCarousel
|
<div
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
ref={resolvedRef}
|
||||||
animationType={carouselAnimationType}
|
className={`relative w-full ${className}`}
|
||||||
title={title}
|
aria-label={ariaLabel}
|
||||||
titleSegments={titleSegments}
|
>
|
||||||
description={description}
|
<div className={`relative ${containerClassName}`}>
|
||||||
tag={tag}
|
{items.map((item) => (
|
||||||
tagIcon={tagIcon}
|
<div key={item.id} className={`relative ${cardClassName}`}>
|
||||||
tagAnimation={tagAnimation}
|
{item.content}
|
||||||
buttons={buttons}
|
</div>
|
||||||
buttonAnimation={buttonAnimation}
|
))}
|
||||||
textboxLayout={textboxLayout}
|
</div>
|
||||||
useInvertedBackground={useInvertedBackground}
|
</div>
|
||||||
bottomContent={bottomContent}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
carouselItemClassName={carouselItemClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</ButtonCarousel>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
CardStack.displayName = "CardStack";
|
CardStack.displayName = "CardStack";
|
||||||
|
|
||||||
export default memo(CardStack);
|
export default CardStack;
|
||||||
|
|||||||
@@ -1,187 +1,35 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, 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";
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
export interface UseCardAnimationOptions {
|
||||||
|
animationType?: "opacity" | "none" | "slide-up" | "blur-reveal";
|
||||||
interface UseCardAnimationProps {
|
staggerDelay?: number;
|
||||||
animationType: CardAnimationType | "depth-3d";
|
duration?: number;
|
||||||
itemCount: number;
|
|
||||||
isGrid?: boolean;
|
|
||||||
supports3DAnimation?: boolean;
|
|
||||||
gridVariant?: GridVariant;
|
|
||||||
useIndividualTriggers?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCardAnimation = ({
|
export const useCardAnimation = (options: UseCardAnimationOptions = {}) => {
|
||||||
animationType,
|
const { animationType = "opacity", staggerDelay = 0.1, duration = 0.6 } =
|
||||||
itemCount,
|
options;
|
||||||
isGrid = true,
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
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
|
useEffect(() => {
|
||||||
const { isMobile } = useDepth3DAnimation({
|
if (!containerRef.current || animationType === "none") return;
|
||||||
itemRefs,
|
|
||||||
|
const cards = containerRef.current.querySelectorAll("[data-card]");
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
const element = card as HTMLElement;
|
||||||
|
element.style.opacity = "0";
|
||||||
|
|
||||||
|
const delay = index * staggerDelay;
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.transition = `opacity ${duration}s ease-in-out`;
|
||||||
|
element.style.opacity = "1";
|
||||||
|
}, delay * 1000);
|
||||||
|
});
|
||||||
|
}, [animationType, staggerDelay, duration]);
|
||||||
|
|
||||||
|
return {
|
||||||
containerRef,
|
containerRef,
|
||||||
perspectiveRef,
|
};
|
||||||
isEnabled: animationType === "depth-3d" && isGrid && supports3DAnimation && gridVariant === "uniform-all-items-equal",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
useGSAP(() => {
|
|
||||||
if (effectiveAnimationType === "none" || effectiveAnimationType === "depth-3d" || itemRefs.current.length === 0) return;
|
|
||||||
|
|
||||||
const items = itemRefs.current.filter((el) => el !== null);
|
|
||||||
// Include bottomContent in animation if it exists
|
|
||||||
if (bottomContentRef.current) {
|
|
||||||
items.push(bottomContentRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [effectiveAnimationType, itemCount, useIndividualTriggers]);
|
|
||||||
|
|
||||||
return { itemRefs, containerRef, perspectiveRef, bottomContentRef };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,149 +1,44 @@
|
|||||||
"use client";
|
import React from "react";
|
||||||
|
import { TimelineItem } from "../types";
|
||||||
|
|
||||||
import React, { Children, useCallback } from "react";
|
export interface TimelineBaseProps {
|
||||||
import { cls } from "@/lib/utils";
|
items: TimelineItem[];
|
||||||
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;
|
title?: string;
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description?: string;
|
description?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
tagIcon?: LucideIcon;
|
animationType?: string;
|
||||||
tagAnimation?: ButtonAnimationType;
|
textboxLayout?: string;
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout?: TextboxLayout;
|
|
||||||
useInvertedBackground?: InvertedBackground;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
textBoxClassName?: string;
|
children?: React.ReactNode;
|
||||||
titleClassName?: string;
|
|
||||||
titleImageWrapperClassName?: string;
|
|
||||||
titleImageClassName?: string;
|
|
||||||
descriptionClassName?: string;
|
|
||||||
tagClassName?: string;
|
|
||||||
buttonContainerClassName?: string;
|
|
||||||
buttonClassName?: string;
|
|
||||||
buttonTextClassName?: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineBase = ({
|
const TimelineBase = React.forwardRef<HTMLDivElement, TimelineBaseProps>(
|
||||||
children,
|
(
|
||||||
variant = "timeline",
|
{
|
||||||
uniformGridCustomHeightClasses = "min-h-80 2xl:min-h-90",
|
items,
|
||||||
animationType,
|
title,
|
||||||
title,
|
description,
|
||||||
titleSegments,
|
tag,
|
||||||
description,
|
animationType = "none", textboxLayout = "default", className = "", containerClassName = "", children,
|
||||||
tag,
|
},
|
||||||
tagIcon,
|
ref
|
||||||
tagAnimation,
|
) => {
|
||||||
buttons,
|
return (
|
||||||
buttonAnimation,
|
<div ref={ref} className={`w-full ${className}`}>
|
||||||
textboxLayout = "default",
|
{tag && <div className="text-sm font-medium mb-2">{tag}</div>}
|
||||||
useInvertedBackground,
|
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
|
||||||
className = "",
|
{description && (
|
||||||
containerClassName = "",
|
<p className="text-base text-foreground/75 mb-8">{description}</p>
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={cls(
|
|
||||||
"relative py-20 w-full",
|
|
||||||
useInvertedBackground && "bg-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}
|
|
||||||
>
|
|
||||||
{(title || titleSegments || description) && (
|
|
||||||
<CardStackTextBox
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div
|
<div className={`relative ${containerClassName}`}>
|
||||||
className={cls(
|
{children}
|
||||||
"relative z-10 flex flex-col gap-6 md:gap-15"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Children.map(childrenArray, (child, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cls("w-65 md:w-25", uniformGridCustomHeightClasses, getItemClasses(index))}
|
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
|
||||||
>
|
|
||||||
{child}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
);
|
||||||
);
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
TimelineBase.displayName = "TimelineBase";
|
TimelineBase.displayName = "TimelineBase";
|
||||||
|
|
||||||
export default React.memo(TimelineBase);
|
export default TimelineBase;
|
||||||
|
|||||||
4
src/components/cardStack/layouts/types.ts
Normal file
4
src/components/cardStack/layouts/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface TimelineItem {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
@@ -1,156 +1,79 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useMemo, useCallback } from "react";
|
import React 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 ProductCatalogItem from "./ProductCatalogItem";
|
||||||
import type { CatalogProduct } from "./ProductCatalogItem";
|
|
||||||
|
|
||||||
interface ProductCatalogProps {
|
export interface CatalogProduct {
|
||||||
layout: "page" | "section";
|
id: string;
|
||||||
products?: CatalogProduct[];
|
name: string;
|
||||||
searchValue?: string;
|
price: string;
|
||||||
onSearchChange?: (value: string) => void;
|
imageSrc: string;
|
||||||
searchPlaceholder?: string;
|
imageAlt?: string;
|
||||||
filters?: ProductVariant[];
|
category?: string;
|
||||||
emptyMessage?: string;
|
rating?: number;
|
||||||
className?: string;
|
reviewCount?: string;
|
||||||
gridClassName?: string;
|
onProductClick?: () => void;
|
||||||
cardClassName?: string;
|
onFavorite?: () => void;
|
||||||
imageClassName?: string;
|
isFavorited?: boolean;
|
||||||
searchClassName?: string;
|
|
||||||
filterClassName?: string;
|
|
||||||
toolbarClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductCatalog = ({
|
export interface ProductCatalogProps {
|
||||||
layout,
|
products: CatalogProduct[];
|
||||||
products: productsProp,
|
title?: string;
|
||||||
searchValue = "",
|
description?: string;
|
||||||
onSearchChange,
|
className?: string;
|
||||||
searchPlaceholder = "Search products...",
|
containerClassName?: string;
|
||||||
filters,
|
gridClassName?: string;
|
||||||
emptyMessage = "No products found",
|
cardClassName?: string;
|
||||||
className = "",
|
onProductClick?: (product: CatalogProduct) => void;
|
||||||
gridClassName = "",
|
ariaLabel?: string;
|
||||||
cardClassName = "",
|
}
|
||||||
imageClassName = "",
|
|
||||||
searchClassName = "",
|
|
||||||
filterClassName = "",
|
|
||||||
toolbarClassName = "",
|
|
||||||
}: ProductCatalogProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
|
||||||
|
|
||||||
const handleProductClick = useCallback((productId: string) => {
|
const ProductCatalog = React.forwardRef<HTMLDivElement, ProductCatalogProps>(
|
||||||
router.push(`/shop/${productId}`);
|
(
|
||||||
}, [router]);
|
{
|
||||||
|
products,
|
||||||
const products: CatalogProduct[] = useMemo(() => {
|
title,
|
||||||
if (productsProp && productsProp.length > 0) {
|
description,
|
||||||
return productsProp;
|
className = "", containerClassName = "", gridClassName = "", cardClassName = "", onProductClick,
|
||||||
}
|
ariaLabel = "Product catalog"},
|
||||||
|
ref
|
||||||
if (fetchedProducts.length === 0) {
|
) => {
|
||||||
return [];
|
const normalizedProducts = Array.isArray(products)
|
||||||
}
|
? products.map((p) => ({
|
||||||
|
...p,
|
||||||
return fetchedProducts.map((product) => ({
|
price: typeof p.price === "number" ? p.price.toString() : p.price,
|
||||||
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 (
|
return (
|
||||||
<section
|
<div
|
||||||
className={cls(
|
ref={ref}
|
||||||
"relative w-content-width mx-auto",
|
className={`w-full ${className}`}
|
||||||
layout === "page" ? "pt-hero-page-padding pb-20" : "py-20",
|
aria-label={ariaLabel}
|
||||||
className
|
>
|
||||||
)}
|
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
|
||||||
>
|
{description && (
|
||||||
{(onSearchChange || (filters && filters.length > 0)) && (
|
<p className="text-base text-foreground/75 mb-8">{description}</p>
|
||||||
<div
|
)}
|
||||||
className={cls(
|
<div className={`grid grid-cols-1 gap-6 ${gridClassName}`}>
|
||||||
"flex flex-col md:flex-row gap-4 md:items-end mb-6",
|
{normalizedProducts.map((product) => (
|
||||||
toolbarClassName
|
<ProductCatalogItem
|
||||||
)}
|
key={product.id}
|
||||||
>
|
product={product}
|
||||||
{onSearchChange && (
|
className={cardClassName}
|
||||||
<Input
|
onProductClick={() => {
|
||||||
value={searchValue}
|
product.onProductClick?.();
|
||||||
onChange={onSearchChange}
|
onProductClick?.(product);
|
||||||
placeholder={searchPlaceholder}
|
}}
|
||||||
ariaLabel={searchPlaceholder}
|
/>
|
||||||
className={cls("flex-1 w-full h-9 text-sm", searchClassName)}
|
))}
|
||||||
/>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ProductCatalog.displayName = "ProductCatalog";
|
ProductCatalog.displayName = "ProductCatalog";
|
||||||
|
|
||||||
export default memo(ProductCatalog);
|
export default ProductCatalog;
|
||||||
|
|||||||
@@ -1,170 +1,143 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ContactForm from "@/components/form/ContactForm";
|
import React, { useState } from "react";
|
||||||
import MediaContent from "@/components/shared/MediaContent";
|
import { Mail } from "lucide-react";
|
||||||
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<
|
export interface ContactSplitProps {
|
||||||
HeroBackgroundVariantProps,
|
tag: string;
|
||||||
| { variant: "plain" }
|
title: string;
|
||||||
| { variant: "animated-grid" }
|
description: string;
|
||||||
| { variant: "canvas-reveal" }
|
tagIcon?: React.ComponentType<any>;
|
||||||
| { variant: "cell-wave" }
|
tagAnimation?: "none" | "opacity" | "slide-up" | "blur-reveal";
|
||||||
| { variant: "downward-rays-animated" }
|
background?: { variant: string };
|
||||||
| { variant: "downward-rays-animated-grid" }
|
useInvertedBackground?: boolean;
|
||||||
| { variant: "downward-rays-static" }
|
imageSrc?: string;
|
||||||
| { variant: "downward-rays-static-grid" }
|
videoSrc?: string;
|
||||||
| { variant: "gradient-bars" }
|
imageAlt?: string;
|
||||||
| { variant: "radial-gradient" }
|
videoAriaLabel?: string;
|
||||||
| { variant: "rotated-rays-animated" }
|
mediaAnimation?: "none" | "opacity" | "slide-up" | "blur-reveal";
|
||||||
| { variant: "rotated-rays-animated-grid" }
|
mediaPosition?: "left" | "right";
|
||||||
| { variant: "rotated-rays-static" }
|
inputPlaceholder?: string;
|
||||||
| { variant: "rotated-rays-static-grid" }
|
buttonText?: string;
|
||||||
| { variant: "sparkles-gradient" }
|
termsText?: string;
|
||||||
>;
|
onSubmit?: (email: string) => void;
|
||||||
|
ariaLabel?: string;
|
||||||
interface ContactSplitProps {
|
className?: string;
|
||||||
title: string;
|
containerClassName?: string;
|
||||||
description: string;
|
contentClassName?: string;
|
||||||
tag: string;
|
tagClassName?: string;
|
||||||
tagIcon?: LucideIcon;
|
titleClassName?: string;
|
||||||
tagAnimation?: ButtonAnimationType;
|
descriptionClassName?: string;
|
||||||
background: ContactSplitBackgroundProps;
|
formWrapperClassName?: string;
|
||||||
useInvertedBackground: boolean;
|
formClassName?: string;
|
||||||
imageSrc?: string;
|
inputClassName?: string;
|
||||||
videoSrc?: string;
|
buttonClassName?: string;
|
||||||
imageAlt?: string;
|
buttonTextClassName?: string;
|
||||||
videoAriaLabel?: string;
|
termsClassName?: string;
|
||||||
mediaPosition?: "left" | "right";
|
mediaWrapperClassName?: string;
|
||||||
mediaAnimation: ButtonAnimationType;
|
mediaClassName?: string;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactSplit = ({
|
const ContactSplit = React.forwardRef<HTMLDivElement, ContactSplitProps>(
|
||||||
title,
|
(
|
||||||
description,
|
{
|
||||||
tag,
|
tag,
|
||||||
tagIcon,
|
title,
|
||||||
tagAnimation,
|
description,
|
||||||
background,
|
tagIcon: TagIcon,
|
||||||
useInvertedBackground,
|
tagAnimation = "none", background,
|
||||||
imageSrc,
|
useInvertedBackground = false,
|
||||||
videoSrc,
|
imageSrc,
|
||||||
imageAlt = "",
|
videoSrc,
|
||||||
videoAriaLabel = "Contact section video",
|
imageAlt = "", videoAriaLabel = "Contact section video", mediaAnimation = "none", mediaPosition = "right", inputPlaceholder = "Enter your email", buttonText = "Sign Up", termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.", onSubmit,
|
||||||
mediaPosition = "right",
|
ariaLabel = "Contact section", className = "", containerClassName = "", contentClassName = "", tagClassName = "", titleClassName = "", descriptionClassName = "", formWrapperClassName = "", formClassName = "", inputClassName = "", buttonClassName = "", buttonTextClassName = "", termsClassName = "", mediaWrapperClassName = "", mediaClassName = ""},
|
||||||
mediaAnimation,
|
ref
|
||||||
inputPlaceholder = "Enter your email",
|
) => {
|
||||||
buttonText = "Sign Up",
|
const [email, setEmail] = useState("");
|
||||||
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 handleSubmit = async (email: string) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
try {
|
e.preventDefault();
|
||||||
await sendContactEmail({ email });
|
onSubmit?.(email);
|
||||||
console.log("Email send successfully");
|
setEmail("");
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send email:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaContent = (
|
|
||||||
<div ref={mediaContainerRef} className={cls("overflow-hidden rounded-theme-capped card h-130", mediaWrapperClassName)}>
|
|
||||||
<MediaContent
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
videoSrc={videoSrc}
|
|
||||||
imageAlt={imageAlt}
|
|
||||||
videoAriaLabel={videoAriaLabel}
|
|
||||||
imageClassName={cls("relative z-1 w-full h-full object-cover", mediaClassName)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
|
<div
|
||||||
<div className={cls("w-content-width mx-auto relative z-10", containerClassName)}>
|
ref={ref}
|
||||||
<div className={cls("grid grid-cols-1 md:grid-cols-2 gap-6 md:auto-rows-fr", contentClassName)}>
|
className={`w-full py-20 ${className}`}
|
||||||
{mediaPosition === "left" && mediaContent}
|
aria-label={ariaLabel}
|
||||||
{contactContent}
|
>
|
||||||
{mediaPosition === "right" && mediaContent}
|
<div className={`max-w-7xl mx-auto px-4 ${containerClassName}`}>
|
||||||
|
<div className={`grid grid-cols-1 md:grid-cols-2 gap-12 ${contentClassName}`}>
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className={formWrapperClassName}>
|
||||||
|
{tag && (
|
||||||
|
<div className={`flex items-center gap-2 mb-4 ${tagClassName}`}>
|
||||||
|
{TagIcon && <TagIcon className="w-4 h-4" />}
|
||||||
|
<span className="text-sm font-medium">{tag}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className={`text-4xl font-bold mb-4 ${titleClassName}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className={`text-lg text-foreground/75 mb-8 ${descriptionClassName}`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className={`space-y-4 ${formClassName}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
required
|
||||||
|
className={`w-full px-4 py-3 bg-secondary-cta text-foreground rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-cta ${inputClassName}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`w-full px-4 py-3 bg-primary-cta text-primary-cta-text font-medium rounded-lg hover:opacity-90 transition-opacity ${buttonClassName}`}
|
||||||
|
>
|
||||||
|
<span className={buttonTextClassName}>{buttonText}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{termsText && (
|
||||||
|
<p className={`text-xs text-foreground/60 mt-4 ${termsClassName}`}>
|
||||||
|
{termsText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
{/* Media */}
|
||||||
|
{(imageSrc || videoSrc) && (
|
||||||
|
<div className={`flex items-center justify-center ${mediaWrapperClassName}`}>
|
||||||
|
{videoSrc ? (
|
||||||
|
<video
|
||||||
|
src={videoSrc}
|
||||||
|
aria-label={videoAriaLabel}
|
||||||
|
controls
|
||||||
|
className={`w-full h-full object-cover rounded-lg ${mediaClassName}`}
|
||||||
|
/>
|
||||||
|
) : imageSrc ? (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={imageAlt}
|
||||||
|
className={`w-full h-full object-cover rounded-lg ${mediaClassName}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ContactSplit.displayName = "ContactSplit";
|
ContactSplit.displayName = "ContactSplit";
|
||||||
|
|
||||||
|
|||||||
@@ -1,238 +1,103 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { Heart } 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 ProductCardFourGridVariant = Exclude<GridVariant, "timeline" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row">;
|
export interface ProductCard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
imageSrc: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
category?: string;
|
||||||
|
rating?: number;
|
||||||
|
reviewCount?: string;
|
||||||
|
isFavorited?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type ProductCard = Product & {
|
export interface ProductCardFourProps {
|
||||||
variant: string;
|
products: ProductCard[];
|
||||||
};
|
title?: string;
|
||||||
|
description?: string;
|
||||||
interface ProductCardFourProps {
|
onFavorite?: (productId: string) => void;
|
||||||
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;
|
|
||||||
className?: 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;
|
gridClassName?: string;
|
||||||
carouselClassName?: string;
|
|
||||||
controlsClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
textBoxTagClassName?: string;
|
|
||||||
textBoxButtonContainerClassName?: string;
|
|
||||||
textBoxButtonClassName?: string;
|
|
||||||
textBoxButtonTextClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductCardItemProps {
|
|
||||||
product: ProductCard;
|
|
||||||
shouldUseLightText: boolean;
|
|
||||||
cardClassName?: string;
|
cardClassName?: string;
|
||||||
imageClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
cardVariantClassName?: string;
|
|
||||||
actionButtonClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
const ProductCardFour = React.forwardRef<
|
||||||
product,
|
HTMLDivElement,
|
||||||
shouldUseLightText,
|
ProductCardFourProps
|
||||||
cardClassName = "",
|
>((
|
||||||
imageClassName = "",
|
{
|
||||||
cardNameClassName = "",
|
products,
|
||||||
cardPriceClassName = "",
|
title,
|
||||||
cardVariantClassName = "",
|
description,
|
||||||
actionButtonClassName = "",
|
onFavorite,
|
||||||
}: ProductCardItemProps) => {
|
className = "", gridClassName = "", cardClassName = ""},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<article
|
<div ref={ref} className={`w-full ${className}`}>
|
||||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
|
||||||
onClick={product.onProductClick}
|
{description && (
|
||||||
role="article"
|
<p className="text-base text-foreground/75 mb-8">{description}</p>
|
||||||
aria-label={`${product.name} - ${product.price}`}
|
)}
|
||||||
>
|
<div className={`grid grid-cols-1 gap-6 ${gridClassName}`}>
|
||||||
<ProductImage
|
{products.map((product) => (
|
||||||
imageSrc={product.imageSrc}
|
<div
|
||||||
imageAlt={product.imageAlt || product.name}
|
key={product.id}
|
||||||
isFavorited={product.isFavorited}
|
className={`relative overflow-hidden rounded-lg ${cardClassName}`}
|
||||||
onFavoriteToggle={product.onFavorite}
|
>
|
||||||
showActionButton={true}
|
{/* Image Container */}
|
||||||
actionButtonAriaLabel={`View ${product.name} details`}
|
<div className="relative aspect-square bg-card overflow-hidden">
|
||||||
imageClassName={imageClassName}
|
{product.imageSrc && (
|
||||||
actionButtonClassName={actionButtonClassName}
|
<img
|
||||||
/>
|
src={product.imageSrc}
|
||||||
|
alt={product.imageAlt || product.name}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Favorite Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onFavorite?.(product.id)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-white/80 hover:bg-white transition-colors"
|
||||||
|
aria-label="Add to favorites"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill={product.isFavorited ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{/* Product Info */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="p-4">
|
||||||
<div className="flex flex-col gap-0 flex-1 min-w-0">
|
{product.category && (
|
||||||
<h3 className={cls("text-base font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
<p className="text-xs font-medium text-primary-cta mb-2">
|
||||||
{product.name}
|
{product.category}
|
||||||
</h3>
|
</p>
|
||||||
<p className={cls("text-sm leading-[1.3]", shouldUseLightText ? "text-background/60" : "text-foreground/60", cardVariantClassName)}>
|
)}
|
||||||
{product.variant}
|
<h3 className="font-semibold mb-2 line-clamp-2">{product.name}</h3>
|
||||||
</p>
|
{product.rating !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm">★ {product.rating}</span>
|
||||||
|
{product.reviewCount && (
|
||||||
|
<span className="text-xs text-foreground/60">
|
||||||
|
({product.reviewCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-bold text-lg">{product.price}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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}`);
|
|
||||||
} 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
cardVariantClassName={cardVariantClassName}
|
|
||||||
actionButtonClassName={actionButtonClassName}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ProductCardFour.displayName = "ProductCardFour";
|
ProductCardFour.displayName = "ProductCardFour";
|
||||||
|
|
||||||
export default ProductCardFour;
|
export default ProductCardFour;
|
||||||
|
|||||||
@@ -1,225 +1,99 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { Heart } from "lucide-react";
|
||||||
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">;
|
export interface Product {
|
||||||
|
id: string;
|
||||||
type ProductCard = Product;
|
name: string;
|
||||||
|
price: string;
|
||||||
interface ProductCardOneProps {
|
imageSrc: string;
|
||||||
products?: ProductCard[];
|
imageAlt?: string;
|
||||||
carouselMode?: "auto" | "buttons";
|
category?: string;
|
||||||
gridVariant: ProductCardOneGridVariant;
|
rating?: number;
|
||||||
uniformGridCustomHeightClasses?: string;
|
reviewCount?: string;
|
||||||
animationType: CardAnimationType;
|
isFavorited?: boolean;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductCardItemProps {
|
export interface ProductCardOneProps {
|
||||||
product: ProductCard;
|
products: Product[];
|
||||||
shouldUseLightText: boolean;
|
title?: string;
|
||||||
cardClassName?: string;
|
description?: string;
|
||||||
imageClassName?: string;
|
onFavorite?: (productId: string) => void;
|
||||||
cardNameClassName?: string;
|
className?: string;
|
||||||
cardPriceClassName?: string;
|
gridClassName?: string;
|
||||||
|
cardClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
const ProductCardOne = React.forwardRef<HTMLDivElement, ProductCardOneProps>((
|
||||||
product,
|
{
|
||||||
shouldUseLightText,
|
products,
|
||||||
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,
|
title,
|
||||||
titleSegments,
|
|
||||||
description,
|
description,
|
||||||
tag,
|
onFavorite,
|
||||||
tagIcon,
|
className = "", gridClassName = "", cardClassName = ""},
|
||||||
tagAnimation,
|
ref
|
||||||
buttons,
|
) => {
|
||||||
buttonAnimation,
|
return (
|
||||||
textboxLayout,
|
<div ref={ref} className={`w-full ${className}`}>
|
||||||
useInvertedBackground,
|
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
|
||||||
ariaLabel = "Product section",
|
{description && (
|
||||||
className = "",
|
<p className="text-base text-foreground/75 mb-8">{description}</p>
|
||||||
containerClassName = "",
|
)}
|
||||||
cardClassName = "",
|
<div className={`grid grid-cols-1 gap-6 ${gridClassName}`}>
|
||||||
imageClassName = "",
|
{products.map((product) => (
|
||||||
textBoxTitleClassName = "",
|
<div
|
||||||
textBoxTitleImageWrapperClassName = "",
|
key={product.id}
|
||||||
textBoxTitleImageClassName = "",
|
className={`relative overflow-hidden rounded-lg ${cardClassName}`}
|
||||||
textBoxDescriptionClassName = "",
|
>
|
||||||
cardNameClassName = "",
|
{/* Image Container */}
|
||||||
cardPriceClassName = "",
|
<div className="relative aspect-square bg-card overflow-hidden">
|
||||||
gridClassName = "",
|
{product.imageSrc && (
|
||||||
carouselClassName = "",
|
<img
|
||||||
controlsClassName = "",
|
src={product.imageSrc}
|
||||||
textBoxClassName = "",
|
alt={product.imageAlt || product.name}
|
||||||
textBoxTagClassName = "",
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</CardStack>
|
{/* Favorite Button */}
|
||||||
);
|
<button
|
||||||
};
|
onClick={() => onFavorite?.(product.id)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-white/80 hover:bg-white transition-colors"
|
||||||
|
aria-label="Add to favorites"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill={product.isFavorited ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
{product.category && (
|
||||||
|
<p className="text-xs font-medium text-primary-cta mb-2">
|
||||||
|
{product.category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 className="font-semibold mb-2 line-clamp-2">{product.name}</h3>
|
||||||
|
{product.rating !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm">★ {product.rating}</span>
|
||||||
|
{product.reviewCount && (
|
||||||
|
<span className="text-xs text-foreground/60">
|
||||||
|
({product.reviewCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-bold text-lg">{product.price}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
ProductCardOne.displayName = "ProductCardOne";
|
ProductCardOne.displayName = "ProductCardOne";
|
||||||
|
|
||||||
|
|||||||
@@ -1,282 +1,102 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useState, useCallback } from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { Heart } from "lucide-react";
|
||||||
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">;
|
export interface ProductCard {
|
||||||
|
id: string;
|
||||||
type ProductCard = Product & {
|
name: string;
|
||||||
onQuantityChange?: (quantity: number) => void;
|
price: string;
|
||||||
initialQuantity?: number;
|
imageSrc: string;
|
||||||
priceButtonProps?: Partial<ButtonPropsForVariant<CTAButtonVariant>>;
|
imageAlt?: string;
|
||||||
};
|
category?: string;
|
||||||
|
rating?: number;
|
||||||
interface ProductCardThreeProps {
|
reviewCount?: string;
|
||||||
products?: ProductCard[];
|
isFavorited?: boolean;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductCardThreeProps {
|
||||||
interface ProductCardItemProps {
|
products: ProductCard[];
|
||||||
product: ProductCard;
|
title?: string;
|
||||||
shouldUseLightText: boolean;
|
description?: string;
|
||||||
isFromApi: boolean;
|
onFavorite?: (productId: string) => void;
|
||||||
onBuyClick?: (productId: string, quantity: number) => void;
|
className?: string;
|
||||||
cardClassName?: string;
|
gridClassName?: string;
|
||||||
imageClassName?: string;
|
cardClassName?: string;
|
||||||
cardNameClassName?: string;
|
|
||||||
quantityControlsClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
const ProductCardThree = React.forwardRef<
|
||||||
product,
|
HTMLDivElement,
|
||||||
shouldUseLightText,
|
ProductCardThreeProps
|
||||||
isFromApi,
|
>((
|
||||||
onBuyClick,
|
{
|
||||||
cardClassName = "",
|
products,
|
||||||
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,
|
title,
|
||||||
titleSegments,
|
|
||||||
description,
|
description,
|
||||||
tag,
|
onFavorite,
|
||||||
tagIcon,
|
className = "", gridClassName = "", cardClassName = ""},
|
||||||
tagAnimation,
|
ref
|
||||||
buttons,
|
) => {
|
||||||
buttonAnimation,
|
return (
|
||||||
textboxLayout,
|
<div ref={ref} className={`w-full ${className}`}>
|
||||||
useInvertedBackground,
|
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
|
||||||
ariaLabel = "Product section",
|
{description && (
|
||||||
className = "",
|
<p className="text-base text-foreground/75 mb-8">{description}</p>
|
||||||
containerClassName = "",
|
)}
|
||||||
cardClassName = "",
|
<div className={`grid grid-cols-1 gap-6 ${gridClassName}`}>
|
||||||
imageClassName = "",
|
{products.map((product) => (
|
||||||
textBoxTitleClassName = "",
|
<div
|
||||||
textBoxTitleImageWrapperClassName = "",
|
key={product.id}
|
||||||
textBoxTitleImageClassName = "",
|
className={`relative overflow-hidden rounded-lg ${cardClassName}`}
|
||||||
textBoxDescriptionClassName = "",
|
>
|
||||||
cardNameClassName = "",
|
{/* Image Container */}
|
||||||
quantityControlsClassName = "",
|
<div className="relative aspect-square bg-card overflow-hidden">
|
||||||
gridClassName = "",
|
{product.imageSrc && (
|
||||||
carouselClassName = "",
|
<img
|
||||||
controlsClassName = "",
|
src={product.imageSrc}
|
||||||
textBoxClassName = "",
|
alt={product.imageAlt || product.name}
|
||||||
textBoxTagClassName = "",
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</CardStack>
|
{/* Favorite Button */}
|
||||||
);
|
<button
|
||||||
};
|
onClick={() => onFavorite?.(product.id)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-white/80 hover:bg-white transition-colors"
|
||||||
|
aria-label="Add to favorites"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill={product.isFavorited ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
{product.category && (
|
||||||
|
<p className="text-xs font-medium text-primary-cta mb-2">
|
||||||
|
{product.category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 className="font-semibold mb-2 line-clamp-2">{product.name}</h3>
|
||||||
|
{product.rating !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm">★ {product.rating}</span>
|
||||||
|
{product.reviewCount && (
|
||||||
|
<span className="text-xs text-foreground/60">
|
||||||
|
({product.reviewCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-bold text-lg">{product.price}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
ProductCardThree.displayName = "ProductCardThree";
|
ProductCardThree.displayName = "ProductCardThree";
|
||||||
|
|
||||||
|
|||||||
@@ -1,266 +1,99 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { Heart } from "lucide-react";
|
||||||
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">;
|
export interface ProductCard {
|
||||||
|
id: string;
|
||||||
type ProductCard = Product & {
|
name: string;
|
||||||
brand: string;
|
price: string;
|
||||||
rating: number;
|
imageSrc: string;
|
||||||
reviewCount: string;
|
imageAlt?: string;
|
||||||
};
|
category?: string;
|
||||||
|
rating?: number;
|
||||||
interface ProductCardTwoProps {
|
reviewCount?: string;
|
||||||
products?: ProductCard[];
|
isFavorited?: boolean;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductCardItemProps {
|
export interface ProductCardTwoProps {
|
||||||
product: ProductCard;
|
products: ProductCard[];
|
||||||
shouldUseLightText: boolean;
|
title?: string;
|
||||||
cardClassName?: string;
|
description?: string;
|
||||||
imageClassName?: string;
|
onFavorite?: (productId: string) => void;
|
||||||
cardBrandClassName?: string;
|
className?: string;
|
||||||
cardNameClassName?: string;
|
gridClassName?: string;
|
||||||
cardPriceClassName?: string;
|
cardClassName?: string;
|
||||||
cardRatingClassName?: string;
|
|
||||||
actionButtonClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
const ProductCardTwo = React.forwardRef<HTMLDivElement, ProductCardTwoProps>((
|
||||||
product,
|
{
|
||||||
shouldUseLightText,
|
products,
|
||||||
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,
|
title,
|
||||||
titleSegments,
|
|
||||||
description,
|
description,
|
||||||
tag,
|
onFavorite,
|
||||||
tagIcon,
|
className = "", gridClassName = "", cardClassName = ""},
|
||||||
tagAnimation,
|
ref
|
||||||
buttons,
|
) => {
|
||||||
buttonAnimation,
|
return (
|
||||||
textboxLayout,
|
<div ref={ref} className={`w-full ${className}`}>
|
||||||
useInvertedBackground,
|
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
|
||||||
ariaLabel = "Product section",
|
{description && (
|
||||||
className = "",
|
<p className="text-base text-foreground/75 mb-8">{description}</p>
|
||||||
containerClassName = "",
|
)}
|
||||||
cardClassName = "",
|
<div className={`grid grid-cols-1 gap-6 ${gridClassName}`}>
|
||||||
imageClassName = "",
|
{products.map((product) => (
|
||||||
textBoxTitleClassName = "",
|
<div
|
||||||
textBoxTitleImageWrapperClassName = "",
|
key={product.id}
|
||||||
textBoxTitleImageClassName = "",
|
className={`relative overflow-hidden rounded-lg ${cardClassName}`}
|
||||||
textBoxDescriptionClassName = "",
|
>
|
||||||
cardBrandClassName = "",
|
{/* Image Container */}
|
||||||
cardNameClassName = "",
|
<div className="relative aspect-square bg-card overflow-hidden">
|
||||||
cardPriceClassName = "",
|
{product.imageSrc && (
|
||||||
cardRatingClassName = "",
|
<img
|
||||||
actionButtonClassName = "",
|
src={product.imageSrc}
|
||||||
gridClassName = "",
|
alt={product.imageAlt || product.name}
|
||||||
carouselClassName = "",
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</CardStack>
|
{/* Favorite Button */}
|
||||||
);
|
<button
|
||||||
};
|
onClick={() => onFavorite?.(product.id)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-white/80 hover:bg-white transition-colors"
|
||||||
|
aria-label="Add to favorites"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill={product.isFavorited ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
{product.category && (
|
||||||
|
<p className="text-xs font-medium text-primary-cta mb-2">
|
||||||
|
{product.category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3 className="font-semibold mb-2 line-clamp-2">{product.name}</h3>
|
||||||
|
{product.rating !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm">★ {product.rating}</span>
|
||||||
|
{product.reviewCount && (
|
||||||
|
<span className="text-xs text-foreground/60">
|
||||||
|
({product.reviewCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-bold text-lg">{product.price}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
ProductCardTwo.displayName = "ProductCardTwo";
|
ProductCardTwo.displayName = "ProductCardTwo";
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,18 @@
|
|||||||
"use client";
|
import { useCallback } from "react";
|
||||||
|
import { fetchProducts } from "@/lib/api/product";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
export const useProduct = () => {
|
||||||
import { Product, fetchProduct } from "@/lib/api/product";
|
const getProducts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const products = await fetchProducts();
|
||||||
|
return products;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching products:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
export function useProduct(productId: string) {
|
return {
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
getProducts,
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user