Initial commit
This commit is contained in:
200
src/App.tsx
Normal file
200
src/App.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Component, lazy, Suspense, useEffect, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { routes } from './routes'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
const pages = import.meta.glob('./pages/*.tsx')
|
||||
|
||||
function getPageComponent(pageFile: string) {
|
||||
const key = `./pages/${pageFile}.tsx`
|
||||
const loader = pages[key]
|
||||
if (!loader) return null
|
||||
return lazy(loader as () => Promise<{ default: React.ComponentType }>)
|
||||
}
|
||||
|
||||
// ─── Render-state probe ────────────────────────────────────────────────────
|
||||
// After every HMR (and on first mount) the iframe reports back to a Vite
|
||||
// dev-only middleware whether the page actually rendered. Bob-AI reads the
|
||||
// resulting file from inside the sandbox after each commit and uses it to
|
||||
// detect "blank preview" / "React threw on mount" failures that escape the
|
||||
// pre-commit gate (esbuild + tsc see only static errors). Sandbox-only —
|
||||
// public deploys never iframe themselves so the probe stays silent.
|
||||
const RENDER_STATUS_URL = '/__webild/render-status'
|
||||
const RENDER_PROBE_DELAY_MS = 1500
|
||||
const RENDER_PROBE_MIN_TEXT_LEN = 30
|
||||
|
||||
interface RenderStatusPayload {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
error?: string
|
||||
stack?: string
|
||||
componentStack?: string
|
||||
filename?: string
|
||||
lineno?: number
|
||||
colno?: number
|
||||
rootChildren?: number
|
||||
bodyTextLen?: number
|
||||
}
|
||||
|
||||
function reportRenderStatus(payload: RenderStatusPayload) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.parent === window) return
|
||||
try {
|
||||
fetch(RENDER_STATUS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, t: Date.now() }),
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function probeRenderState() {
|
||||
if (typeof document === 'undefined') return
|
||||
const root = document.getElementById('root')
|
||||
const text = (document.body?.innerText || '').trim()
|
||||
if (!root || root.children.length === 0) {
|
||||
reportRenderStatus({
|
||||
ok: false,
|
||||
reason: 'empty_root',
|
||||
rootChildren: 0,
|
||||
bodyTextLen: text.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (text.length < RENDER_PROBE_MIN_TEXT_LEN) {
|
||||
reportRenderStatus({
|
||||
ok: false,
|
||||
reason: 'blank_render',
|
||||
rootChildren: root.children.length,
|
||||
bodyTextLen: text.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
reportRenderStatus({
|
||||
ok: true,
|
||||
rootChildren: root.children.length,
|
||||
bodyTextLen: text.length,
|
||||
})
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__webildRenderProbeInstalled__?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.parent !== window && !window.__webildRenderProbeInstalled__) {
|
||||
window.__webildRenderProbeInstalled__ = true
|
||||
window.addEventListener('error', (e) => {
|
||||
reportRenderStatus({
|
||||
ok: false,
|
||||
reason: 'window_error',
|
||||
error: String(e?.message || (e as ErrorEvent)?.error?.message || 'unknown'),
|
||||
stack: String((e as ErrorEvent)?.error?.stack || '').slice(0, 4000),
|
||||
filename: String((e as ErrorEvent)?.filename || ''),
|
||||
lineno: (e as ErrorEvent)?.lineno,
|
||||
colno: (e as ErrorEvent)?.colno,
|
||||
})
|
||||
})
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = (e as PromiseRejectionEvent).reason
|
||||
reportRenderStatus({
|
||||
ok: false,
|
||||
reason: 'unhandled_rejection',
|
||||
error: String(reason?.message || reason || 'unknown'),
|
||||
stack: String(reason?.stack || '').slice(0, 4000),
|
||||
})
|
||||
})
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('vite:afterUpdate', () => {
|
||||
setTimeout(probeRenderState, RENDER_PROBE_DELAY_MS)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class RenderErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
|
||||
state = { hasError: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
reportRenderStatus({
|
||||
ok: false,
|
||||
reason: 'react_error_boundary',
|
||||
error: String(error?.message || error || 'unknown'),
|
||||
stack: String(error?.stack || '').slice(0, 4000),
|
||||
componentStack: String(info?.componentStack || '').slice(0, 4000),
|
||||
})
|
||||
}
|
||||
render() {
|
||||
return this.state.hasError ? null : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function useRenderProbe() {
|
||||
useEffect(() => {
|
||||
const id = setTimeout(probeRenderState, RENDER_PROBE_DELAY_MS)
|
||||
return () => clearTimeout(id)
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Selection bridge: Alt+click on a `[data-webild-section]` block posts the
|
||||
// section name to the parent (Webild editor) so the next /edit request can
|
||||
// be scoped surgically. Sandbox-only — the Webild editor is the parent;
|
||||
// public visitors hitting the deployed site never trigger this.
|
||||
function useWebildSelectionBridge() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.parent === window) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!e.altKey) return
|
||||
let el = e.target as HTMLElement | null
|
||||
while (el && el !== document.body) {
|
||||
const name = el.getAttribute?.('data-webild-section')
|
||||
if (name) {
|
||||
e.preventDefault()
|
||||
window.parent.postMessage(
|
||||
{ type: 'webild:section-click', sectionName: name },
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
el = el.parentElement
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', handler, true)
|
||||
return () => window.removeEventListener('click', handler, true)
|
||||
}, [])
|
||||
}
|
||||
|
||||
function App() {
|
||||
useWebildSelectionBridge()
|
||||
useRenderProbe()
|
||||
return (
|
||||
<RenderErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-svh bg-background">
|
||||
<div className="w-8 h-8 border-2 border-foreground border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{routes.map((route) => {
|
||||
const Page = getPageComponent(route.pageFile)
|
||||
if (!Page) return null
|
||||
return <Route key={route.path} path={route.path} element={<Page />} />
|
||||
})}
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</RenderErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
15
src/components/Layout.tsx
Normal file
15
src/components/Layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { StyleProvider } from '@/components/ui/StyleProvider';
|
||||
import SiteBackgroundSlot from '@/components/ui/SiteBackgroundSlot';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<StyleProvider buttonVariant="default" siteBackground="none" heroBackground="none">
|
||||
<SiteBackgroundSlot />
|
||||
<main className="flex-grow">
|
||||
<Outlet />
|
||||
</main>
|
||||
</StyleProvider>
|
||||
);
|
||||
}
|
||||
126
src/components/ecommerce/ProductCart.tsx
Normal file
126
src/components/ecommerce/ProductCart.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect } from "react";
|
||||
import { X, Plus, Minus, Trash2 } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
type ProductCartProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
items: CartItem[];
|
||||
total: string;
|
||||
onQuantityChange?: (id: string, quantity: number) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onCheckout?: () => void;
|
||||
};
|
||||
|
||||
const ProductCart = ({ isOpen, onClose, items, total, onQuantityChange, onRemove, onCheckout }: ProductCartProps) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isOpen ? "hidden" : "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-1001">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="absolute inset-0 bg-foreground/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<motion.aside
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="card absolute right-0 top-0 flex flex-col p-5 h-screen w-screen md:w-96"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-foreground">Cart ({items.length})</h2>
|
||||
<button onClick={onClose} className="card flex items-center justify-center size-8 rounded cursor-pointer" aria-label="Close cart">
|
||||
<X className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 h-px w-full bg-foreground/5" />
|
||||
|
||||
<div className="flex-1 py-5 min-h-0 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="py-20 text-center text-sm text-foreground/50">Your cart is empty</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-4">
|
||||
<div className="shrink-0 size-24 overflow-hidden rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} className="size-full object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-semibold text-foreground truncate">{item.name}</h3>
|
||||
<p className="shrink-0 text-base font-semibold text-foreground">{item.price}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => item.quantity > 1 && onQuantityChange?.(item.id, item.quantity - 1)}
|
||||
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Minus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
<span className="min-w-5 text-center text-sm font-semibold text-foreground">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => onQuantityChange?.(item.id, item.quantity + 1)}
|
||||
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Plus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove?.(item.id)}
|
||||
className="card flex items-center justify-center ml-auto size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="h-px w-full bg-foreground/5" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-foreground">Total</span>
|
||||
<span className="text-base font-semibold text-foreground">{total}</span>
|
||||
</div>
|
||||
<Button text="Checkout" onClick={onCheckout} variant="primary" className="w-full" />
|
||||
</div>
|
||||
</motion.aside>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCart;
|
||||
export type { CartItem };
|
||||
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Star, ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
import type { ProductVariant } from "./ProductDetailCard";
|
||||
|
||||
type CatalogProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
category?: string;
|
||||
rating?: number;
|
||||
reviewCount?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ProductCatalogProps = {
|
||||
products?: CatalogProduct[];
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
filters?: ProductVariant[];
|
||||
};
|
||||
|
||||
const ProductCatalog = ({ products: productsProp, searchValue = "", onSearchChange, filters }: ProductCatalogProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
|
||||
const products: CatalogProduct[] = productsProp && productsProp.length > 0
|
||||
? productsProp
|
||||
: fetchedProducts.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
imageSrc: p.imageSrc,
|
||||
category: p.brand,
|
||||
rating: p.rating,
|
||||
reviewCount: p.reviewCount,
|
||||
onClick: p.onProductClick,
|
||||
}));
|
||||
|
||||
if (isLoading && (!productsProp || productsProp.length === 0)) {
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-8 text-foreground animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
{(onSearchChange || (filters && filters.length > 0)) && (
|
||||
<div className="flex flex-col gap-5 mb-5 md:flex-row md:items-end">
|
||||
{onSearchChange && (
|
||||
<div className="flex flex-1 flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-semibold text-foreground">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
className="card px-4 h-9 w-full md:w-80 text-base text-foreground bg-transparent rounded focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-5 items-end">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.label} className="flex flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-semibold text-foreground">{filter.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||
<select
|
||||
value={filter.selected}
|
||||
onChange={(e) => filter.onChange(e.target.value)}
|
||||
className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none"
|
||||
>
|
||||
{filter.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="py-20 text-center text-sm text-foreground/50">No products found</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={product.onClick}
|
||||
className="card group h-full flex flex-col gap-3 p-3 text-left rounded cursor-pointer"
|
||||
>
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={product.imageSrc} className="size-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 flex items-center justify-center transition-all duration-300 group-hover:bg-background/20 group-hover:backdrop-blur-xs">
|
||||
<div className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100">
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{product.category && (
|
||||
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.category}</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-xl font-semibold text-foreground truncate">{product.name}</h3>
|
||||
{product.rating && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cls("size-4 text-accent", i < Math.floor(product.rating || 0) ? "fill-accent" : "opacity-20")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{product.reviewCount && (
|
||||
<span className="text-sm text-foreground">({product.reviewCount})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-semibold text-foreground">{product.price}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCatalog;
|
||||
export type { CatalogProduct };
|
||||
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
|
||||
type ProductVariant = {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type ProductDetailCardProps = {
|
||||
name: string;
|
||||
price: string;
|
||||
salePrice?: string;
|
||||
images: string[];
|
||||
description?: string;
|
||||
rating?: number;
|
||||
ribbon?: string;
|
||||
inventoryStatus?: "in-stock" | "out-of-stock";
|
||||
inventoryQuantity?: number;
|
||||
sku?: string;
|
||||
variants?: ProductVariant[];
|
||||
quantity?: ProductVariant;
|
||||
onAddToCart?: () => void;
|
||||
onBuyNow?: () => void;
|
||||
};
|
||||
|
||||
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
<div className="flex flex-col gap-5 md:flex-row">
|
||||
<div className="relative md:w-1/2">
|
||||
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
|
||||
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
|
||||
</Transition>
|
||||
{images.length > 1 && (
|
||||
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(i)}
|
||||
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
|
||||
>
|
||||
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
|
||||
<div className={cls(
|
||||
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
|
||||
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
|
||||
)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<h2 className="flex-1 text-2xl font-semibold text-foreground md:text-3xl">{name}</h2>
|
||||
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-semibold rounded text-secondary-cta-text">{ribbon}</span>}
|
||||
</div>
|
||||
<div className="h-px w-full bg-foreground/5" />
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-foreground md:text-2xl">
|
||||
{salePrice ? (
|
||||
<>
|
||||
<span className="text-foreground/75 line-through mr-1">{price}</span>
|
||||
<span>{salePrice}</span>
|
||||
</>
|
||||
) : (
|
||||
price
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(inventoryStatus || inventoryQuantity || sku) && (
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{inventoryStatus && (
|
||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
|
||||
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
|
||||
</span>
|
||||
)}
|
||||
{inventoryQuantity && (
|
||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
|
||||
)}
|
||||
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
|
||||
</div>
|
||||
)}
|
||||
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
|
||||
{variants && variants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-5">
|
||||
{variants.map((variant) => (
|
||||
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-semibold text-foreground">{variant.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||
{variant.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{quantity && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold text-foreground">{quantity.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
|
||||
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||
{quantity.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col mt-auto gap-3 pt-5">
|
||||
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
|
||||
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailCard;
|
||||
export type { ProductVariant };
|
||||
13
src/components/layouts/DefaultsLayout.tsx
Normal file
13
src/components/layouts/DefaultsLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const DefaultsLayout = () => {
|
||||
useEffect(() => {
|
||||
document.body.classList.add('use-defaults');
|
||||
return () => document.body.classList.remove('use-defaults');
|
||||
}, []);
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default DefaultsLayout;
|
||||
90
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
90
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type AboutFeaturesSplitProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: { icon: string | LucideIcon; title: string; description: string }[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const AboutFeaturesSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: AboutFeaturesSplitProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10 mx-auto w-content-width">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
|
||||
<div className="flex flex-col justify-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 w-full md:w-4/10 2xl:w-35/100 card rounded">
|
||||
{items.map((item, index) => {
|
||||
const ItemIcon = resolveIcon(item.icon);
|
||||
return (
|
||||
<div key={item.title}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
|
||||
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">{item.title}</h3>
|
||||
<p className="text-base leading-snug">{item.description}</p>
|
||||
</div>
|
||||
{index < items.length - 1 && (
|
||||
<div className="mt-4 xl:mt-5 2xl:mt-6 border-b border-accent/40" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-px w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
|
||||
<div className="relative size-full">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutFeaturesSplit;
|
||||
72
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
72
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type AboutMediaOverlayProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const AboutMediaOverlay = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: AboutMediaOverlayProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="relative flex items-center justify-center py-8 md:py-12 mx-auto w-content-width rounded overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
<div className="absolute inset-0 bg-foreground/40 backdrop-blur-xs pointer-events-none select-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-center px-5 py-10 mx-auto min-h-100 md:min-h-120 md:w-1/2 w-content-width">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="mb-1 px-3 py-1 text-sm card rounded"
|
||||
>
|
||||
{tag}
|
||||
</motion.span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutMediaOverlay;
|
||||
87
src/components/sections/about/AboutParallax.tsx
Normal file
87
src/components/sections/about/AboutParallax.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useRef } from "react";
|
||||
import { motion, useScroll, useTransform } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type AboutParallaxProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
badge?: string;
|
||||
} & ({ frontImageSrc: string; frontVideoSrc?: never } | { frontVideoSrc: string; frontImageSrc?: never }) &
|
||||
({ backImageSrc: string; backVideoSrc?: never } | { backVideoSrc: string; backImageSrc?: never });
|
||||
|
||||
const AboutParallax = ({ tag, title, description, primaryButton, secondaryButton, frontImageSrc, frontVideoSrc, backImageSrc, backVideoSrc, badge }: AboutParallaxProps) => {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
|
||||
const fgY = useTransform(scrollYProgress, [0, 1], ["120px", "-120px"]);
|
||||
const bgY = useTransform(scrollYProgress, [0, 1], ["-60px", "60px"]);
|
||||
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
aria-label="About section"
|
||||
className="relative py-20"
|
||||
>
|
||||
<div className="mx-auto w-content-width">
|
||||
<div className="flex flex-col md:flex-row items-center gap-8 md:gap-16">
|
||||
<div className="w-full md:w-45/100 flex flex-col gap-3">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-55/100 relative h-100 md:h-125 xl:h-150 2xl:h-175">
|
||||
<div className="absolute top-0 right-0 w-75/100 h-full overflow-hidden rounded-none">
|
||||
<motion.div className="w-full h-full" style={{ y: bgY, scale: bgScale }}>
|
||||
<ImageOrVideo imageSrc={backImageSrc} videoSrc={backVideoSrc} className="rounded-none" />
|
||||
</motion.div>
|
||||
{badge && (
|
||||
<span className="absolute top-2 right-2 xl:top-3 xl:right-3 2xl:top-4 2xl:right-4 px-3 py-1.5 text-xs text-foreground font-medium card rounded-none">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute top-15/100 left-0 w-65/100 h-70/100 z-10 overflow-hidden rounded-none"
|
||||
style={{ y: fgY }}
|
||||
>
|
||||
<ImageOrVideo imageSrc={frontImageSrc} videoSrc={frontVideoSrc} className="rounded-none" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutParallax;
|
||||
58
src/components/sections/about/AboutTestimonial.tsx
Normal file
58
src/components/sections/about/AboutTestimonial.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Quote } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type AboutTestimonialProps = {
|
||||
tag: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const AboutTestimonial = ({
|
||||
tag,
|
||||
quote,
|
||||
author,
|
||||
role,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: AboutTestimonialProps) => {
|
||||
return (
|
||||
<section aria-label="Testimonial section" className="py-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
|
||||
<div className="relative md:col-span-3 p-10 md:p-20 card rounded">
|
||||
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
|
||||
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col justify-center gap-5 h-full">
|
||||
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={quote}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="h1"
|
||||
className="text-4xl md:text-5xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base font-medium truncate">{author}</span>
|
||||
<span className="text-accent shrink-0">•</span>
|
||||
<span className="text-base font-medium truncate">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="p-px md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutTestimonial;
|
||||
105
src/components/sections/about/AboutTestimonialParallax.tsx
Normal file
105
src/components/sections/about/AboutTestimonialParallax.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useRef } from "react";
|
||||
import { useScroll, useTransform, motion } from "motion/react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Quote } from "lucide-react";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type SocialLink = {
|
||||
icon: string | LucideIcon;
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type AboutTestimonialParallaxProps = {
|
||||
tag: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
socialLinks?: SocialLink[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const SocialLinkButton = ({ icon, label, href, onClick }: SocialLink) => {
|
||||
const Icon = resolveIcon(icon);
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-center gap-2 h-9 px-3 text-sm rounded-full cursor-pointer backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20 text-primary-cta-text font-medium hover:bg-primary-cta-text/25 transition-all duration-300 ease-out"
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const AboutTestimonialParallax = ({
|
||||
tag,
|
||||
quote,
|
||||
author,
|
||||
role,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
socialLinks,
|
||||
}: AboutTestimonialParallaxProps) => {
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: imageRef,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
const imageScale = useTransform(scrollYProgress, [0, 0.6], [1.3, 1]);
|
||||
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
|
||||
<div className="relative md:col-span-3 p-10 md:p-20 card rounded">
|
||||
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
|
||||
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col justify-center gap-5 h-full">
|
||||
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={quote}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="h1"
|
||||
className="text-4xl md:text-5xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base font-medium truncate">{author}</span>
|
||||
<span className="text-accent shrink-0">•</span>
|
||||
<span className="text-base font-medium truncate">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={imageRef} className="p-px md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden relative">
|
||||
<motion.div style={{ scale: imageScale }} className="w-full h-full origin-center">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</motion.div>
|
||||
|
||||
{socialLinks && socialLinks.length > 0 && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-center gap-3 p-6 xl:p-7 2xl:p-8">
|
||||
{socialLinks.map((link, index) => (
|
||||
<SocialLinkButton key={index} {...link} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutTestimonialParallax;
|
||||
37
src/components/sections/about/AboutText.tsx
Normal file
37
src/components/sections/about/AboutText.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
|
||||
interface AboutTextProps {
|
||||
title: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
}
|
||||
|
||||
const AboutText = ({
|
||||
title,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: AboutTextProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-2 items-center">
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 justify-center mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutText;
|
||||
58
src/components/sections/about/AboutTextSplit.tsx
Normal file
58
src/components/sections/about/AboutTextSplit.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
|
||||
interface AboutTextSplitProps {
|
||||
title: string;
|
||||
descriptions: string[];
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
}
|
||||
|
||||
const AboutTextSplit = ({
|
||||
title,
|
||||
descriptions,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: AboutTextSplitProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="flex flex-col gap-20 mx-auto w-content-width">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
|
||||
<div className="w-full md:w-1/2">
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full md:w-1/2">
|
||||
{descriptions.map((desc, index) => (
|
||||
<TextAnimation
|
||||
key={index}
|
||||
text={desc}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-xl md:text-2xl leading-snug text-balance"
|
||||
/>
|
||||
))}
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-b border-foreground/5" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutTextSplit;
|
||||
91
src/components/sections/blog/BlogArticle.tsx
Normal file
91
src/components/sections/blog/BlogArticle.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type BlogArticleProps = {
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
imageSrc: string;
|
||||
authorName: string;
|
||||
authorImageSrc: string;
|
||||
date: string;
|
||||
readingTime?: string;
|
||||
backButton?: { text: string; href: string };
|
||||
};
|
||||
|
||||
const BlogArticle = ({
|
||||
category,
|
||||
title,
|
||||
excerpt,
|
||||
content,
|
||||
imageSrc,
|
||||
authorName,
|
||||
authorImageSrc,
|
||||
date,
|
||||
readingTime,
|
||||
backButton = { text: "Back to Blog", href: "/blog" },
|
||||
}: BlogArticleProps) => {
|
||||
return (
|
||||
<article aria-label="Blog article" className="py-20">
|
||||
<div className="flex flex-col gap-10">
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div className="flex flex-col gap-3 w-content-width md:max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-2 px-3 py-1 mb-1 text-sm text-foreground/75 card rounded w-fit">
|
||||
<a
|
||||
href={backButton.href}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{backButton.text}
|
||||
</a>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{category}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{excerpt && (
|
||||
<p className="text-lg md:text-xl leading-snug text-balance">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={authorImageSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{authorName}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">
|
||||
{date}
|
||||
{readingTime && ` · ${readingTime}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div className="w-content-width md:max-w-4xl mx-auto aspect-video card rounded overflow-hidden p-2 xl:p-3 2xl:p-4">
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div
|
||||
className="w-content-width md:max-w-4xl mx-auto flex flex-col gap-6 [&>h1]:text-4xl [&>h1]:font-semibold [&>h1]:mt-4 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:mt-4 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:mt-2 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:mt-2 [&>p]:text-base [&>p]:leading-relaxed [&>p]:text-foreground/85 [&_strong]:font-semibold [&_em]:italic [&_u]:underline [&>ul]:flex [&>ul]:flex-col [&>ul]:gap-2 [&>ul]:list-disc [&>ul]:pl-6 [&>ul]:text-base [&>ul]:leading-relaxed [&>ul]:text-foreground/85 [&>ol]:flex [&>ol]:flex-col [&>ol]:gap-2 [&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:text-base [&>ol]:leading-relaxed [&>ol]:text-foreground/85"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogArticle;
|
||||
160
src/components/sections/blog/BlogMediaCards.tsx
Normal file
160
src/components/sections/blog/BlogMediaCards.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||
|
||||
type BlogItem = {
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
authorName: string;
|
||||
authorImageSrc: string;
|
||||
date: string;
|
||||
imageSrc: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
||||
const handleClick = useButtonClick(item.href, item.onClick);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card group flex flex-col justify-between gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{item.category}</p>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type BlogMediaCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items?: BlogItem[];
|
||||
};
|
||||
|
||||
const BlogMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items: itemsProp,
|
||||
}: BlogMediaCardsProps) => {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
const isFromApi = posts.length > 0;
|
||||
const items = isFromApi
|
||||
? posts.map((p) => ({
|
||||
category: p.category,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
authorName: p.authorName,
|
||||
authorImageSrc: p.authorAvatar,
|
||||
date: p.date,
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onBlogClick,
|
||||
}))
|
||||
: itemsProp;
|
||||
|
||||
if (isLoading && !itemsProp) {
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogMediaCards;
|
||||
159
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
159
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||
|
||||
type BlogItem = {
|
||||
category: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
authorName: string;
|
||||
authorImageSrc: string;
|
||||
date: string;
|
||||
imageSrc: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
||||
const handleClick = useButtonClick(item.href, item.onClick);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative aspect-4/3 rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm primary-button text-primary-cta-text rounded w-fit">
|
||||
<p>{item.category}</p>
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type BlogSimpleCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items?: BlogItem[];
|
||||
};
|
||||
|
||||
const BlogSimpleCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items: itemsProp,
|
||||
}: BlogSimpleCardsProps) => {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
const isFromApi = posts.length > 0;
|
||||
const items = isFromApi
|
||||
? posts.map((p) => ({
|
||||
category: p.category,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
authorName: p.authorName,
|
||||
authorImageSrc: p.authorAvatar,
|
||||
date: p.date,
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onBlogClick,
|
||||
}))
|
||||
: itemsProp;
|
||||
|
||||
if (isLoading && !itemsProp) {
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogSimpleCards;
|
||||
163
src/components/sections/blog/BlogTagCards.tsx
Normal file
163
src/components/sections/blog/BlogTagCards.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||
|
||||
type BlogItem = {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
authorName: string;
|
||||
authorImageSrc: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
imageSrc: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
||||
const handleClick = useButtonClick(item.href, item.onClick);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative aspect-4/3 rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 mb-1">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-6 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-foreground/75 truncate">
|
||||
{item.authorName} • {item.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2 md:mt-3">
|
||||
{item.tags.map((tag, index) => (
|
||||
<div key={index} className="px-3 py-1 text-sm primary-button text-primary-cta-text rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type BlogTagCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items?: BlogItem[];
|
||||
};
|
||||
|
||||
const BlogTagCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items: itemsProp,
|
||||
}: BlogTagCardsProps) => {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
const isFromApi = posts.length > 0;
|
||||
const items = isFromApi
|
||||
? posts.map((p) => ({
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
authorName: p.authorName,
|
||||
authorImageSrc: p.authorAvatar,
|
||||
date: p.date,
|
||||
tags: [p.category],
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onBlogClick,
|
||||
}))
|
||||
: itemsProp;
|
||||
|
||||
if (isLoading && !itemsProp) {
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogTagCards;
|
||||
99
src/components/sections/contact/ContactCenter.tsx
Normal file
99
src/components/sections/contact/ContactCenter.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { sendContactEmail } from "@/lib/api/email";
|
||||
|
||||
type ContactCenterProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputPlaceholder: string;
|
||||
buttonText: string;
|
||||
onSubmit?: (email: string) => void;
|
||||
};
|
||||
|
||||
const ContactCenter = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputPlaceholder,
|
||||
buttonText,
|
||||
onSubmit,
|
||||
}: ContactCenterProps) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await sendContactEmail({ email });
|
||||
onSubmit?.(email);
|
||||
setEmail("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up" className="flex items-center justify-center py-20 card rounded">
|
||||
<div className="flex flex-col items-center w-full md:w-1/2 gap-2 px-5">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-9/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-9/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 md:mt-3 card rounded"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={inputPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Sending..." : buttonText}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactCenter;
|
||||
46
src/components/sections/contact/ContactCta.tsx
Normal file
46
src/components/sections/contact/ContactCta.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
const ContactCta = ({
|
||||
tag,
|
||||
text,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: {
|
||||
tag: string;
|
||||
text: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
}) => {
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div className="flex flex-col items-center gap-8 md:gap-10 py-20 px-8 rounded card">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={text}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-5xl 2xl:text-6xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactCta;
|
||||
108
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
108
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { sendContactEmail } from "@/lib/api/email";
|
||||
|
||||
type ContactSplitEmailProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputPlaceholder: string;
|
||||
buttonText: string;
|
||||
onSubmit?: (email: string) => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ContactSplitEmail = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputPlaceholder,
|
||||
buttonText,
|
||||
onSubmit,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: ContactSplitEmailProps) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await sendContactEmail({ email });
|
||||
onSubmit?.(email);
|
||||
setEmail("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="flex items-center justify-center py-15 md:py-20 card rounded">
|
||||
<div className="flex flex-col items-center w-full gap-2 px-5">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 md:mt-3 card rounded"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={inputPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Sending..." : buttonText}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-100 md:h-auto md:aspect-square card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitEmail;
|
||||
155
src/components/sections/contact/ContactSplitForm.tsx
Normal file
155
src/components/sections/contact/ContactSplitForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { sendContactEmail } from "@/lib/api/email";
|
||||
|
||||
type InputField = {
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type TextareaField = {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type ContactSplitFormProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputs: InputField[];
|
||||
textarea?: TextareaField;
|
||||
buttonText: string;
|
||||
onSubmit?: (data: Record<string, string>) => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ContactSplitForm = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputs,
|
||||
textarea,
|
||||
buttonText,
|
||||
onSubmit,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: ContactSplitFormProps) => {
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
initial[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
initial[textarea.name] = "";
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await sendContactEmail({ formData });
|
||||
onSubmit?.(formData);
|
||||
const reset: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
reset[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
reset[textarea.name] = "";
|
||||
}
|
||||
setFormData(reset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="p-6 md:p-10 card rounded">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{inputs.map((input) => (
|
||||
<input
|
||||
key={input.name}
|
||||
type={input.type}
|
||||
placeholder={input.placeholder}
|
||||
value={formData[input.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||
required={input.required}
|
||||
aria-label={input.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
|
||||
/>
|
||||
))}
|
||||
|
||||
{textarea && (
|
||||
<textarea
|
||||
placeholder={textarea.placeholder}
|
||||
value={formData[textarea.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||
required={textarea.required}
|
||||
rows={textarea.rows || 5}
|
||||
aria-label={textarea.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center w-full h-10 px-6 text-sm primary-button text-primary-cta-text rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Sending..." : buttonText}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="h-100 md:h-auto card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover rounded" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitForm;
|
||||
202
src/components/sections/contact/ContactSplitFormParallax.tsx
Normal file
202
src/components/sections/contact/ContactSplitFormParallax.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useScroll, useTransform, motion } from "motion/react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { sendContactEmail } from "@/lib/api/email";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type InputField = {
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type TextareaField = {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type CtaLink = {
|
||||
icon: string | LucideIcon;
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ContactSplitFormParallaxProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputs: InputField[];
|
||||
textarea?: TextareaField;
|
||||
buttonText: string;
|
||||
onSubmit?: (data: Record<string, string>) => void;
|
||||
ctaLinks?: CtaLink[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const CtaLinkButton = ({ icon, label, href, onClick }: CtaLink) => {
|
||||
const Icon = resolveIcon(icon);
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-center gap-2 h-9 px-3 text-sm rounded-full cursor-pointer backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20 text-primary-cta-text font-semibold hover:bg-primary-cta-text/25 transition-all duration-300 ease-out"
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactSplitFormParallax = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputs,
|
||||
textarea,
|
||||
buttonText,
|
||||
onSubmit,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
ctaLinks,
|
||||
}: ContactSplitFormParallaxProps) => {
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
initial[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
initial[textarea.name] = "";
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await sendContactEmail({ formData });
|
||||
onSubmit?.(formData);
|
||||
const reset: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
reset[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
reset[textarea.name] = "";
|
||||
}
|
||||
setFormData(reset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: imageRef,
|
||||
offset: ["start end", "end start"],
|
||||
});
|
||||
const imageScale = useTransform(scrollYProgress, [0, 0.6], [1.3, 1]);
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="p-6 md:p-10 card rounded">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{inputs.map((input) => (
|
||||
<input
|
||||
key={input.name}
|
||||
type={input.type}
|
||||
placeholder={input.placeholder}
|
||||
value={formData[input.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||
required={input.required}
|
||||
aria-label={input.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
|
||||
/>
|
||||
))}
|
||||
|
||||
{textarea && (
|
||||
<textarea
|
||||
placeholder={textarea.placeholder}
|
||||
value={formData[textarea.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||
required={textarea.required}
|
||||
rows={textarea.rows || 5}
|
||||
aria-label={textarea.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center w-full h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Sending..." : buttonText}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div ref={imageRef} className="relative h-100 md:h-auto card rounded overflow-hidden">
|
||||
<motion.div style={{ scale: imageScale }} className="w-full h-full origin-center">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="md:absolute md:inset-0 size-full object-cover" />
|
||||
</motion.div>
|
||||
|
||||
{ctaLinks && ctaLinks.length > 0 && (
|
||||
<div className="absolute inset-0 flex flex-wrap items-end justify-center gap-3 p-6 xl:p-7 2xl:p-8">
|
||||
{ctaLinks.map((link, index) => (
|
||||
<CtaLinkButton key={index} {...link} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitFormParallax;
|
||||
155
src/components/sections/contact/ContactSplitFormSharp.tsx
Normal file
155
src/components/sections/contact/ContactSplitFormSharp.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { sendContactEmail } from "@/lib/api/email";
|
||||
|
||||
type InputField = {
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type TextareaField = {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type ContactSplitFormSharpProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputs: InputField[];
|
||||
textarea?: TextareaField;
|
||||
buttonText: string;
|
||||
onSubmit?: (data: Record<string, string>) => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ContactSplitFormSharp = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputs,
|
||||
textarea,
|
||||
buttonText,
|
||||
onSubmit,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: ContactSplitFormSharpProps) => {
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
initial[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
initial[textarea.name] = "";
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await sendContactEmail({ formData });
|
||||
onSubmit?.(formData);
|
||||
const reset: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
reset[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
reset[textarea.name] = "";
|
||||
}
|
||||
setFormData(reset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="p-6 md:p-10 card rounded-none">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded-none w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{inputs.map((input) => (
|
||||
<input
|
||||
key={input.name}
|
||||
type={input.type}
|
||||
placeholder={input.placeholder}
|
||||
value={formData[input.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||
required={input.required}
|
||||
aria-label={input.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded-none"
|
||||
/>
|
||||
))}
|
||||
|
||||
{textarea && (
|
||||
<textarea
|
||||
placeholder={textarea.placeholder}
|
||||
value={formData[textarea.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||
required={textarea.required}
|
||||
rows={textarea.rows || 5}
|
||||
aria-label={textarea.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center w-full h-10 px-6 text-sm primary-button text-primary-cta-text rounded-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Sending..." : buttonText}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="h-100 md:h-auto card rounded-none overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover rounded-none" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitFormSharp;
|
||||
107
src/components/sections/faq/FaqSimple.tsx
Normal file
107
src/components/sections/faq/FaqSimple.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
const FaqSimple = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FaqItem[];
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const handleToggle = (index: number) => {
|
||||
setActiveIndex(activeIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="FAQ section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleToggle(index)}
|
||||
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-base leading-snug">{item.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqSimple;
|
||||
122
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
122
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
type FaqSplitMediaProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FaqItem[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const FaqSplitMedia = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: FaqSplitMediaProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const handleToggle = (index: number) => {
|
||||
setActiveIndex(activeIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="FAQ section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
|
||||
<ScrollReveal variant="slide-up" className="card relative md:col-span-2 h-80 md:h-auto rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.1} className="md:col-span-3 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleToggle(index)}
|
||||
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-base leading-snug">{item.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqSplitMedia;
|
||||
109
src/components/sections/faq/FaqTabbedAccordion.tsx
Normal file
109
src/components/sections/faq/FaqTabbedAccordion.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import SelectorButton from "@/components/ui/SelectorButton";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
import Accordion from "@/components/ui/Accordion";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
type FaqCategory = {
|
||||
name: string;
|
||||
items: FaqItem[];
|
||||
};
|
||||
|
||||
interface FaqTabbedAccordionProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
categories: FaqCategory[];
|
||||
cta?: {
|
||||
name: string;
|
||||
role: string;
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
}
|
||||
|
||||
const FaqTabbedAccordion = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
categories,
|
||||
cta,
|
||||
}: FaqTabbedAccordionProps) => {
|
||||
const [activeCategory, setActiveCategory] = useState(categories[0]?.name || "");
|
||||
|
||||
const currentItems = categories.find((c) => c.name === activeCategory)?.items || [];
|
||||
const accordionItems = currentItems.map((item) => ({ title: item.question, content: item.answer }));
|
||||
|
||||
return (
|
||||
<section aria-label="FAQ section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="card rounded flex flex-col gap-6 md:gap-10 p-6 md:p-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
<SelectorButton
|
||||
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||
activeValue={activeCategory}
|
||||
onValueChange={setActiveCategory}
|
||||
className="mt-2 md:mt-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<Transition key={activeCategory} whileInView={false} className="">
|
||||
<Accordion items={accordionItems} />
|
||||
</Transition>
|
||||
</ScrollReveal>
|
||||
|
||||
{cta && (
|
||||
<>
|
||||
<div className="w-full h-px bg-foreground/5" />
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6 justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={cta.imageSrc}
|
||||
videoSrc={cta.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-base text-foreground font-semibold leading-snug truncate">{cta.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug truncate">{cta.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button text={cta.buttonText} href={cta.buttonHref} variant="primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqTabbedAccordion;
|
||||
122
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
122
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
const FaqTwoColumn = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FaqItem[];
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const handleToggle = (index: number) => {
|
||||
setActiveIndex(activeIndex === index ? null : index);
|
||||
};
|
||||
|
||||
const halfLength = Math.ceil(items.length / 2);
|
||||
const firstColumn = items.slice(0, halfLength);
|
||||
const secondColumn = items.slice(halfLength);
|
||||
|
||||
const renderAccordionItem = (item: FaqItem, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleToggle(index)}
|
||||
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-base leading-snug">{item.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label="FAQ section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="card rounded p-6 xl:p-10">
|
||||
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
|
||||
</div>
|
||||
{secondColumn.length > 0 && (
|
||||
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqTwoColumn;
|
||||
143
src/components/sections/features/FeaturesAlternatingBento.tsx
Normal file
143
src/components/sections/features/FeaturesAlternatingBento.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
|
||||
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
|
||||
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
|
||||
import MediaStack from "@/components/ui/MediaStack";
|
||||
import { cls } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type IconInput = string | LucideIcon;
|
||||
|
||||
type FeatureItem = { title: string; description: string; primaryButton?: { text: string; href: string }; secondaryButton?: { text: string; href: string } } & (
|
||||
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
|
||||
| { bentoComponent: "animated-bar-chart" }
|
||||
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
||||
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
|
||||
);
|
||||
|
||||
const getBentoComponent = (item: FeatureItem) => {
|
||||
switch (item.bentoComponent) {
|
||||
case "info-card-marquee": return <InfoCardMarquee items={item.infoCards} />;
|
||||
case "animated-bar-chart": return <AnimatedBarChart />;
|
||||
case "checklist-timeline": return <ChecklistTimeline heading={item.heading} subheading={item.subheading} items={item.checklistItems} completedLabel={item.completedLabel} />;
|
||||
case "media-stack": return <MediaStack items={item.mediaItems} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface FeaturesAlternatingBentoProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesAlternatingBento = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesAlternatingBentoProps) => {
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
itemRefs.current.forEach((ref, position) => {
|
||||
if (!ref) return;
|
||||
|
||||
const isLast = position === itemRefs.current.length - 1;
|
||||
|
||||
gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ref,
|
||||
start: "center center",
|
||||
end: "+=100%",
|
||||
scrub: true,
|
||||
},
|
||||
})
|
||||
.set(ref, { willChange: "opacity" })
|
||||
.to(ref, {
|
||||
ease: "none",
|
||||
opacity: isLast ? 1 : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [items.length]);
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 md:gap-[6vh] w-content-width mx-auto">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cls("sticky top-[25vw] md:top-[12.5vh] h-[140vw] md:h-[75vh] flex flex-col gap-6 md:gap-10 p-6 md:p-10 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
|
||||
>
|
||||
<div className="flex flex-col justify-center w-full md:w-1/2 gap-2">
|
||||
<div className="flex items-center justify-center size-9 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-balance">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-1/2 h-full rounded overflow-hidden bg-foreground/5 p-3 xl:p-4 2xl:p-5">
|
||||
{getBentoComponent(item)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAlternatingBento;
|
||||
128
src/components/sections/features/FeaturesAlternatingSplit.tsx
Normal file
128
src/components/sections/features/FeaturesAlternatingSplit.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesAlternatingSplitProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesAlternatingSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesAlternatingSplitProps) => {
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
itemRefs.current.forEach((ref, position) => {
|
||||
if (!ref) return;
|
||||
|
||||
const isLast = position === itemRefs.current.length - 1;
|
||||
|
||||
gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ref,
|
||||
start: "center center",
|
||||
end: "+=100%",
|
||||
scrub: true,
|
||||
},
|
||||
})
|
||||
.set(ref, { willChange: "opacity" })
|
||||
.to(ref, {
|
||||
ease: "none",
|
||||
opacity: isLast ? 1 : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [items.length]);
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 md:gap-[6vh] w-content-width mx-auto">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
className={cls("sticky top-[25vw] md:top-[12.5vh] h-[140vw] md:h-[75vh] flex flex-col gap-6 md:gap-10 p-6 md:p-10 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
|
||||
>
|
||||
<div className="flex flex-col justify-center w-full md:w-1/2 gap-2">
|
||||
<div className="flex items-center justify-center size-9 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-balance">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAlternatingSplit;
|
||||
107
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
107
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
tags: string[];
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ArrowButton = ({ href, onClick }: { href?: string; onClick?: () => void }) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="group/arrow flex items-center justify-center shrink-0 size-9 primary-button rounded-full cursor-pointer transition-transform duration-300 hover:scale-110"
|
||||
>
|
||||
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover/arrow:rotate-45" strokeWidth={2} />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeaturesArrowCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesArrowCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesArrowCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4">
|
||||
<ArrowButton href={item.href} onClick={item.onClick} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 md:mt-3">
|
||||
{item.tags.map((itemTag) => (
|
||||
<div key={itemTag} className="flex items-center h-9 px-3 text-sm card rounded">
|
||||
<p>{itemTag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesArrowCards;
|
||||
99
src/components/sections/features/FeaturesAttributeCards.tsx
Normal file
99
src/components/sections/features/FeaturesAttributeCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type AttributeDetail = {
|
||||
icon: string | LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
tags: string;
|
||||
badge?: string | null;
|
||||
details: AttributeDetail[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesAttributeCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesAttributeCards = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesAttributeCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features attribute cards section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="group flex flex-col gap-2 xl:gap-3 2xl:gap-4 h-full rounded-none">
|
||||
<div className="relative aspect-4/3 overflow-hidden rounded-none">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none group-hover:scale-105 transition-transform duration-500" />
|
||||
{item.badge && (
|
||||
<span className="absolute top-2 left-2 xl:top-3 xl:left-3 2xl:top-4 2xl:left-4 px-3 py-1 text-sm text-foreground font-medium card rounded-none">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
|
||||
<p className="text-base leading-snug">{item.tags}</p>
|
||||
<div className="flex items-center gap-3 text-base mt-0.5">
|
||||
{item.details.map((detail) => {
|
||||
const IconComponent = resolveIcon(detail.icon);
|
||||
return (
|
||||
<span key={detail.label} className="flex items-center gap-1">
|
||||
<IconComponent className="size-[1em]" strokeWidth={1.5} />
|
||||
{detail.label}: {detail.value}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAttributeCards;
|
||||
103
src/components/sections/features/FeaturesBento.tsx
Normal file
103
src/components/sections/features/FeaturesBento.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
|
||||
import TiltedStackCards from "@/components/ui/TiltedStackCards";
|
||||
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
|
||||
import OrbitingIcons from "@/components/ui/OrbitingIcons";
|
||||
import IconTextMarquee from "@/components/ui/IconTextMarquee";
|
||||
import ChatMarquee from "@/components/ui/ChatMarquee";
|
||||
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
|
||||
import MediaStack from "@/components/ui/MediaStack";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type IconInput = string | LucideIcon;
|
||||
|
||||
type FeatureCard = { title: string; description: string } & (
|
||||
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
|
||||
| { bentoComponent: "tilted-stack-cards"; stackCards: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
|
||||
| { bentoComponent: "animated-bar-chart" }
|
||||
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; orbitIcons: IconInput[] }
|
||||
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; marqueeTexts: string[] }
|
||||
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
|
||||
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
||||
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
|
||||
);
|
||||
|
||||
const getBentoComponent = (feature: FeatureCard) => {
|
||||
switch (feature.bentoComponent) {
|
||||
case "info-card-marquee": return <InfoCardMarquee items={feature.infoCards} />;
|
||||
case "tilted-stack-cards": return <TiltedStackCards items={feature.stackCards} />;
|
||||
case "animated-bar-chart": return <AnimatedBarChart />;
|
||||
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.orbitIcons} />;
|
||||
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.marqueeTexts} />;
|
||||
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
|
||||
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.checklistItems} completedLabel={feature.completedLabel} />;
|
||||
case "media-stack": return <MediaStack items={feature.mediaItems} />;
|
||||
}
|
||||
};
|
||||
|
||||
const FeaturesBento = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
features,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: FeatureCard[];
|
||||
}) => (
|
||||
<section aria-label="Features bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded h-full">
|
||||
<div className="relative h-72 overflow-hidden rounded p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5">{getBentoComponent(feature)}</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
||||
<p className="text-base leading-snug">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default FeaturesBento;
|
||||
83
src/components/sections/features/FeaturesBentoGrid.tsx
Normal file
83
src/components/sections/features/FeaturesBentoGrid.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesBentoGridProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
}
|
||||
|
||||
const FeaturesBentoGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: FeaturesBentoGridProps) => {
|
||||
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="w-content-width mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
||||
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBentoGrid;
|
||||
112
src/components/sections/features/FeaturesBentoGridCta.tsx
Normal file
112
src/components/sections/features/FeaturesBentoGridCta.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesBentoGridCtaProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
ctaButton?: {
|
||||
text: string;
|
||||
href: string;
|
||||
avatarSrc?: string;
|
||||
avatarLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const FeaturesBentoGridCta = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
ctaButton,
|
||||
}: FeaturesBentoGridCtaProps) => {
|
||||
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{ctaButton && (
|
||||
<ScrollReveal variant="slide-up" delay={0.2}>
|
||||
<a
|
||||
href={ctaButton.href}
|
||||
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
|
||||
>
|
||||
{ctaButton.avatarSrc && (
|
||||
<div className="flex items-center">
|
||||
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
||||
<img
|
||||
src={ctaButton.avatarSrc}
|
||||
className="w-9 h-9 rounded-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
|
||||
<div className="overflow-hidden flex items-center">
|
||||
<span className="text-primary-cta-text text-sm font-semibold mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
|
||||
+
|
||||
</span>
|
||||
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
|
||||
<span className="w-9 h-9 rounded-full flex items-center justify-center">
|
||||
<span className="text-foreground text-xs font-bold">{ctaButton.avatarLabel || "You"}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-base font-semibold whitespace-nowrap">{ctaButton.text}</span>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="w-content-width mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
||||
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBentoGridCta;
|
||||
86
src/components/sections/features/FeaturesBorderGlow.tsx
Normal file
86
src/components/sections/features/FeaturesBorderGlow.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Button from "@/components/ui/Button";
|
||||
import BorderGlow from "@/components/ui/BorderGlow";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
interface FeaturesBorderGlowProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesBorderGlow = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
features,
|
||||
}: FeaturesBorderGlowProps) => {
|
||||
return (
|
||||
<section aria-label="Features border glow section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => {
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
return (
|
||||
<div key={feature.title} className="relative flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 mt-0.5 h-full min-h-60 md:min-h-70 2xl:min-h-80 card rounded">
|
||||
<div className="flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded-full">
|
||||
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||
</div>
|
||||
<BorderGlow />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBorderGlow;
|
||||
85
src/components/sections/features/FeaturesComparison.tsx
Normal file
85
src/components/sections/features/FeaturesComparison.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
interface FeaturesComparisonProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
negativeItems: string[];
|
||||
positiveItems: string[];
|
||||
}
|
||||
|
||||
const FeaturesComparison = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
negativeItems,
|
||||
positiveItems,
|
||||
}: FeaturesComparisonProps) => {
|
||||
return (
|
||||
<section aria-label="Features comparison section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 w-content-width md:w-6/10 mx-auto gap-5">
|
||||
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded opacity-50">
|
||||
{negativeItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 secondary-button rounded">
|
||||
<X className="size-3 text-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded">
|
||||
{positiveItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesComparison;
|
||||
94
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
94
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesDetailedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesDetailedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesDetailedCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||
{items.map((item) => (
|
||||
<ScrollReveal
|
||||
variant="slide-up"
|
||||
key={item.title}
|
||||
className="flex flex-col md:grid md:grid-cols-2 mx-auto gap-6 md:gap-20 p-6 md:p-10 card rounded group"
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-2">
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||
|
||||
<div className="flex flex-col-reverse md:flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{item.tags.map((itemTag) => (
|
||||
<div key={itemTag} className="px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{itemTag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-lg md:text-xl leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square md:aspect-5/4 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedCards;
|
||||
100
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
100
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type StepItem = {
|
||||
tag: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesDetailedStepsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
steps: StepItem[];
|
||||
}
|
||||
|
||||
const FeaturesDetailedSteps = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
steps,
|
||||
}: FeaturesDetailedStepsProps) => {
|
||||
return (
|
||||
<section aria-label="Features detailed steps section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||
{steps.map((step, index) => {
|
||||
const stepNumber = String(index + 1).padStart(2, "0");
|
||||
return (
|
||||
<ScrollReveal
|
||||
variant="slide-up"
|
||||
key={step.title}
|
||||
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-6 p-6 md:p-10 card rounded overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col justify-between w-full md:w-1/2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{step.tag}</p>
|
||||
</div>
|
||||
<h3 className="text-7xl md:text-8xl font-semibold leading-[1.15] text-balance">{step.title}</h3>
|
||||
</div>
|
||||
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-2xl md:text-3xl font-semibold leading-snug text-balance">{step.subtitle}</h4>
|
||||
<p className="text-base md:text-lg leading-snug text-balance">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full md:w-35/100 gap-10">
|
||||
<span className="hidden md:block self-end text-7xl md:text-8xl font-semibold text-accent">{stepNumber}</span>
|
||||
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
|
||||
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedSteps;
|
||||
117
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
117
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
descriptions: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesFlipCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeatureFlipCard = ({ item }: { item: FeatureItem }) => {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full cursor-pointer perspective-[3000px]"
|
||||
onClick={() => setIsFlipped(!isFlipped)}
|
||||
>
|
||||
<div
|
||||
data-flipped={isFlipped}
|
||||
className="relative w-full h-full transition-transform duration-500 transform-3d data-[flipped=true]:transform-[rotateY(180deg)]"
|
||||
>
|
||||
<div className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden">
|
||||
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||
<Plus className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden aspect-4/5 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden transform-[rotateY(180deg)]">
|
||||
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||
<Plus className="size-4 rotate-45 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5 rounded">
|
||||
{item.descriptions.map((desc, index) => (
|
||||
<p key={index} className="text-base md:text-lg leading-snug text-balance">{desc}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesFlipCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesFlipCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<FeatureFlipCard key={item.title} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesFlipCards;
|
||||
105
src/components/sections/features/FeaturesGridSplit.tsx
Normal file
105
src/components/sections/features/FeaturesGridSplit.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type BottomFeatureItem = FeatureItem & {
|
||||
primaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
};
|
||||
|
||||
interface FeaturesGridSplitProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
topItems: [FeatureItem, FeatureItem];
|
||||
bottomItem: BottomFeatureItem;
|
||||
}
|
||||
|
||||
const FeaturesGridSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
topItems,
|
||||
bottomItem,
|
||||
}: FeaturesGridSplitProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-3 xl:gap-3.5 2xl:gap-4">
|
||||
{topItems.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||
<div className="aspect-square rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-lg leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||
<div className="flex flex-col gap-1 justify-center md:w-1/2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{bottomItem.title}</h3>
|
||||
<p className="text-lg leading-snug text-balance">{bottomItem.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={bottomItem.primaryButton.text} href={bottomItem.primaryButton.href} variant="primary" />
|
||||
{bottomItem.avatarsSrc && bottomItem.avatarsSrc.length > 0 && (
|
||||
<AvatarGroup avatarsSrc={bottomItem.avatarsSrc} size="md" label={bottomItem.avatarsLabel} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/2 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={bottomItem.imageSrc} videoSrc={bottomItem.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesGridSplit;
|
||||
87
src/components/sections/features/FeaturesIconCards.tsx
Normal file
87
src/components/sections/features/FeaturesIconCards.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HoverPattern from "@/components/ui/HoverPattern";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
interface FeaturesIconCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesIconCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
features,
|
||||
}: FeaturesIconCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features icon cards section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => {
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
return (
|
||||
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||
<HoverPattern className="flex items-center justify-center aspect-square rounded bg-foreground/5 shadow shadow-foreground/5">
|
||||
<div className="relative z-10 flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded shadow">
|
||||
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</HoverPattern>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
||||
<p className="text-base leading-snug">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesIconCards;
|
||||
112
src/components/sections/features/FeaturesImageBento.tsx
Normal file
112
src/components/sections/features/FeaturesImageBento.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesImageBentoProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesImageBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesImageBentoProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features image bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => {
|
||||
const content = (
|
||||
<div className="relative h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-5 bottom-5 xl:inset-x-6 xl:bottom-6 2xl:inset-x-7 2xl:bottom-7 flex flex-col text-background">
|
||||
<span className="text-2xl font-semibold leading-snug truncate">{item.title}</span>
|
||||
<span className="text-base leading-snug truncate">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollReveal key={index} variant="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
{item.href ? (
|
||||
<a href={item.href} className="block overflow-hidden rounded">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesImageBento;
|
||||
81
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
81
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesMediaCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel >
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||
<div className="aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
|
||||
<p className="text-base leading-snug">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesMediaCards;
|
||||
101
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
101
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import LoopCarousel from "@/components/ui/LoopCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonIcon: string | LucideIcon;
|
||||
buttonHref?: string;
|
||||
buttonOnClick?: () => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesMediaCarouselProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeatureMediaCarouselCard = ({ item }: { item: FeatureItem }) => {
|
||||
const handleClick = useButtonClick(item.buttonHref, item.buttonOnClick);
|
||||
const Icon = resolveIcon(item.buttonIcon);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden aspect-square md:aspect-3/2 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex items-center justify-between gap-5 p-4 xl:p-5 2xl:p-6 card rounded backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-2xl font-semibold leading-snug truncate">{item.title}</h3>
|
||||
<p className="text-base leading-snug truncate">{item.description}</p>
|
||||
</div>
|
||||
<a
|
||||
href={item.buttonHref}
|
||||
onClick={handleClick}
|
||||
aria-label="View more"
|
||||
className="flex items-center justify-center shrink-0 size-9 cursor-pointer primary-button rounded-full"
|
||||
>
|
||||
<Icon className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesMediaCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaCarouselProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="w-full py-20">
|
||||
<div className="flex flex-col w-full gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LoopCarousel>
|
||||
{items.map((item) => (
|
||||
<FeatureMediaCarouselCard key={item.title} item={item} />
|
||||
))}
|
||||
</LoopCarousel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesMediaCarousel;
|
||||
82
src/components/sections/features/FeaturesMediaGrid.tsx
Normal file
82
src/components/sections/features/FeaturesMediaGrid.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesMediaGridProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesMediaGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaGridProps) => {
|
||||
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 h-full">
|
||||
<div className="aspect-square overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesMediaGrid;
|
||||
110
src/components/sections/features/FeaturesResultsComparison.tsx
Normal file
110
src/components/sections/features/FeaturesResultsComparison.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type ResultItem = {
|
||||
treatment: string;
|
||||
detail: string;
|
||||
beforeSrc: string;
|
||||
afterSrc: string;
|
||||
};
|
||||
|
||||
interface FeaturesResultsComparisonProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: ResultItem[];
|
||||
}
|
||||
|
||||
const ImageLabel = ({ text, side }: { text: string; side: "left" | "right" }) => (
|
||||
<div
|
||||
className={cls(
|
||||
"absolute bottom-3 xl:bottom-3.5 2xl:bottom-4 px-3 py-1 w-fit text-sm card rounded",
|
||||
side === "left" ? "left-3 xl:left-3.5 2xl:left-4" : "right-3 xl:right-3.5 2xl:right-4"
|
||||
)}
|
||||
>
|
||||
<p className="font-medium text-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FeaturesResultsComparison = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesResultsComparisonProps) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<section aria-label="Results section" className="pt-20 pb-10">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x-medium">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="shrink-0 w-80 md:w-120 2xl:w-140 mb-10 mr-3 md:mr-5 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||
<div className="relative flex w-full aspect-3/2">
|
||||
<div className="relative overflow-hidden w-1/2 rounded-l-lg rounded-r-none">
|
||||
<ImageOrVideo imageSrc={item.beforeSrc} className="absolute inset-0 object-cover w-full h-full rounded-l rounded-r-none" />
|
||||
<ImageLabel text="Before" side="left" />
|
||||
</div>
|
||||
<div className="absolute z-10 left-1/2 top-0 bottom-0 w-0.5 bg-background -translate-x-1/2" />
|
||||
<div className="relative overflow-hidden w-1/2 rounded-r-lg rounded-l-none">
|
||||
<ImageOrVideo imageSrc={item.afterSrc} className="absolute inset-0 object-cover w-full h-full rounded-r rounded-l-none" />
|
||||
<ImageLabel text="After" side="right" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h4 className="truncate text-2xl font-semibold leading-snug">
|
||||
{item.treatment}
|
||||
</h4>
|
||||
<p className="text-base leading-snug">
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesResultsComparison;
|
||||
103
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
103
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Info } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesRevealCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesRevealCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
|
||||
<div className="absolute top-4 left-4 xl:top-6 xl:left-6 2xl:top-8 2xl:left-8 z-20 perspective-[1000px]">
|
||||
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm rounded bg-background backface-hidden text-foreground">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
|
||||
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="absolute inset-x-2 bottom-2 xl:inset-x-3 xl:bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-0 group-hover:gap-1 xl:group-hover:gap-2 2xl:group-hover:gap-3 p-2 xl:p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-white transition-colors duration-400 group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-sm leading-snug text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCards;
|
||||
109
src/components/sections/features/FeaturesRevealCardsBento.tsx
Normal file
109
src/components/sections/features/FeaturesRevealCardsBento.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsBentoProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesRevealCardsBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={item.title} variant="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
<a href={item.href} className="block relative overflow-hidden rounded">
|
||||
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCardsBento;
|
||||
@@ -0,0 +1,109 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesRevealCardsBentoSharpProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
|
||||
const gridClasses = [
|
||||
"md:col-span-2",
|
||||
"md:col-span-4",
|
||||
"md:col-span-3",
|
||||
"md:col-span-3",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
"md:col-span-2",
|
||||
];
|
||||
|
||||
const staggerDelays = [
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0,
|
||||
0.1,
|
||||
0.2,
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={item.title} variant="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||
<a href={item.href} className="block relative overflow-hidden rounded-none">
|
||||
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="rounded-none group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||
<div className="absolute inset-0 -z-10 card rounded-none translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCardsBentoSharp;
|
||||
233
src/components/sections/features/FeaturesStickyCards.tsx
Normal file
233
src/components/sections/features/FeaturesStickyCards.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & (
|
||||
| { leftImageSrc: string; leftVideoSrc?: never }
|
||||
| { leftVideoSrc: string; leftImageSrc?: never }
|
||||
) & (
|
||||
| { rightImageSrc: string; rightVideoSrc?: never }
|
||||
| { rightVideoSrc: string; rightImageSrc?: never }
|
||||
);
|
||||
|
||||
interface FeaturesStickyCardsProps {
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const CardFrame = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
cardRef,
|
||||
className = "",
|
||||
}: {
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
cardRef: (el: HTMLDivElement | null) => void;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div ref={cardRef} className={cls("card rounded p-1 overflow-hidden", className)}>
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const FeaturesStickyCards = ({
|
||||
items,
|
||||
}: FeaturesStickyCardsProps) => {
|
||||
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const triggerRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
const getAnimationConfig = (itemIndex: number, isLeftCard: boolean) => {
|
||||
const isOddItem = itemIndex % 2 === 1;
|
||||
if (isLeftCard) {
|
||||
return {
|
||||
from: { xPercent: -225, rotation: -45 },
|
||||
to: { rotation: isOddItem ? 10 : -10 },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
from: { xPercent: 225, rotation: 45 },
|
||||
to: { rotation: isOddItem ? -10 : 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const animateCards = (isMobile: boolean) => {
|
||||
items.forEach((_, itemIndex) => {
|
||||
[0, 1].forEach((cardIndex) => {
|
||||
const refIndex = itemIndex * 2 + cardIndex;
|
||||
const element = isMobile
|
||||
? mobileImageRefs.current[refIndex]
|
||||
: imageRefs.current[refIndex];
|
||||
|
||||
if (element) {
|
||||
const isLeftCard = cardIndex === 0;
|
||||
|
||||
const fromConfig = isMobile
|
||||
? {
|
||||
xPercent: isLeftCard ? -150 : 150,
|
||||
rotation: isLeftCard ? -25 : 25,
|
||||
}
|
||||
: getAnimationConfig(itemIndex, isLeftCard).from;
|
||||
|
||||
const toConfig = isMobile
|
||||
? {
|
||||
xPercent: 0,
|
||||
rotation: 0,
|
||||
duration: 1,
|
||||
scrollTrigger: {
|
||||
trigger: element,
|
||||
start: "top 90%",
|
||||
end: "top 50%",
|
||||
scrub: 1,
|
||||
},
|
||||
}
|
||||
: {
|
||||
xPercent: 0,
|
||||
rotation: getAnimationConfig(itemIndex, isLeftCard).to.rotation,
|
||||
scrollTrigger: {
|
||||
trigger: triggerRefs.current[itemIndex],
|
||||
start: "top bottom",
|
||||
end: "top top",
|
||||
scrub: 1,
|
||||
},
|
||||
};
|
||||
|
||||
gsap.fromTo(element, fromConfig, toConfig);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
mm.add("(max-width: 767px)", () => animateCards(true));
|
||||
mm.add("(min-width: 768px)", () => animateCards(false));
|
||||
|
||||
return () => {
|
||||
mm.revert();
|
||||
imageRefs.current = [];
|
||||
mobileImageRefs.current = [];
|
||||
triggerRefs.current = [];
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const sectionHeightStyle = { height: `${items.length * 100}vh` };
|
||||
|
||||
return (
|
||||
<section aria-label="Features sticky cards section" className="py-20 overflow-hidden md:overflow-visible">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="hidden md:flex relative" style={sectionHeightStyle}>
|
||||
<div
|
||||
className="absolute top-0 left-0 flex flex-col w-6/10 mx-auto right-0 z-10"
|
||||
style={sectionHeightStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => { triggerRefs.current[index] = el; }}
|
||||
className="w-full mx-auto h-screen flex justify-center items-center"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||
<p>{index + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-5xl md:text-6xl font-semibold text-center text-balance">{item.title}</h3>
|
||||
<p className="md:max-w-6/10 text-lg leading-snug text-center">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="h-screen w-full absolute top-0 left-0">
|
||||
<div className="w-content-width mx-auto h-full flex flex-row justify-between items-center">
|
||||
<CardFrame
|
||||
imageSrc={item.leftImageSrc}
|
||||
videoSrc={item.leftVideoSrc}
|
||||
cardRef={(el) => {
|
||||
imageRefs.current[itemIndex * 2] = el;
|
||||
}}
|
||||
className="w-25/100 xl:w-27/100 2xl:w-29/100 h-[70vh]"
|
||||
/>
|
||||
<CardFrame
|
||||
imageSrc={item.rightImageSrc}
|
||||
videoSrc={item.rightVideoSrc}
|
||||
cardRef={(el) => {
|
||||
imageRefs.current[itemIndex * 2 + 1] = el;
|
||||
}}
|
||||
className="w-25/100 xl:w-27/100 2xl:w-28/100 h-[70vh]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex flex-col gap-20 w-content-width mx-auto">
|
||||
{items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||
<p>{itemIndex + 1}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold text-center text-balance">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-snug text-center">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 justify-center">
|
||||
<CardFrame
|
||||
imageSrc={item.leftImageSrc}
|
||||
videoSrc={item.leftVideoSrc}
|
||||
cardRef={(el) => {
|
||||
mobileImageRefs.current[itemIndex * 2] = el;
|
||||
}}
|
||||
className="w-1/2 aspect-9/16"
|
||||
/>
|
||||
<CardFrame
|
||||
imageSrc={item.rightImageSrc}
|
||||
videoSrc={item.rightVideoSrc}
|
||||
cardRef={(el) => {
|
||||
mobileImageRefs.current[itemIndex * 2 + 1] = el;
|
||||
}}
|
||||
className="w-1/2 aspect-9/16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesStickyCards;
|
||||
89
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
89
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesTaggedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesTaggedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesTaggedCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4 px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{item.tag}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between flex-1 gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-snug text-balance">{item.description}</p>
|
||||
</div>
|
||||
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" className="w-full mt-2 md:mt-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesTaggedCards;
|
||||
121
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
121
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesTimelineCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: [FeatureItem, FeatureItem, FeatureItem];
|
||||
}
|
||||
|
||||
const FeaturesTimelineCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesTimelineCardsProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
|
||||
}, 50);
|
||||
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === 100) {
|
||||
setActiveIndex((i) => (i + 1) % items.length);
|
||||
}
|
||||
}, [progress, items.length]);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (index !== activeIndex) setActiveIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Features timeline section" className="py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition className="flex flex-col gap-5">
|
||||
<div className="relative aspect-square md:aspect-10/4 overflow-hidden card rounded">
|
||||
<Transition key={activeIndex} transitionType="fade" className="absolute inset-px overflow-hidden rounded">
|
||||
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
data-active={index === activeIndex}
|
||||
onClick={() => handleCardClick(index)}
|
||||
className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center size-8 primary-button rounded">
|
||||
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
|
||||
</div>
|
||||
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-tight text-balance">{item.description}</p>
|
||||
</div>
|
||||
<div className="relative w-full h-px overflow-hidden">
|
||||
<div className="absolute inset-0 bg-foreground/20" />
|
||||
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesTimelineCards;
|
||||
64
src/components/sections/footer/FooterBasic.tsx
Normal file
64
src/components/sections/footer/FooterBasic.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBasic = ({
|
||||
columns,
|
||||
leftText,
|
||||
rightText,
|
||||
}: {
|
||||
columns: FooterColumn[];
|
||||
leftText: string;
|
||||
rightText: string;
|
||||
}) => {
|
||||
return (
|
||||
<footer
|
||||
aria-label="Site footer"
|
||||
className="w-full pt-20 pb-10 border-t border-foreground/15"
|
||||
>
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="w-full flex flex-wrap justify-between gap-y-10 mb-10">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20" />
|
||||
|
||||
<div className="w-full flex items-center justify-between pt-5">
|
||||
<span className="text-sm opacity-50">{leftText}</span>
|
||||
<span className="text-sm opacity-50">{rightText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBasic;
|
||||
66
src/components/sections/footer/FooterBrand.tsx
Normal file
66
src/components/sections/footer/FooterBrand.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBrand = ({
|
||||
brand,
|
||||
columns,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
}) => {
|
||||
return (
|
||||
<footer
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 mt-20 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||
<AutoFillText className="font-semibold">{brand}</AutoFillText>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||
)}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="flex flex-col items-start gap-3">
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBrand;
|
||||
101
src/components/sections/footer/FooterBrandReveal.tsx
Normal file
101
src/components/sections/footer/FooterBrandReveal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBrandReveal = ({
|
||||
brand,
|
||||
columns,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
}) => {
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (footerRef.current) {
|
||||
setFooterHeight(footerRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
if (footerRef.current) {
|
||||
resizeObserver.observe(footerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative z-0 w-full mt-20"
|
||||
style={{
|
||||
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fixed bottom-0 w-full"
|
||||
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||
>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||
<AutoFillText className="font-semibold">{brand}</AutoFillText>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||
)}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="flex flex-col items-start gap-3">
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBrandReveal;
|
||||
57
src/components/sections/footer/FooterMinimal.tsx
Normal file
57
src/components/sections/footer/FooterMinimal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type SocialLink = {
|
||||
icon: string | LucideIcon;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const SocialLinkItem = ({ icon, href, onClick }: SocialLink) => {
|
||||
const Icon = resolveIcon(icon);
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-center size-10 rounded-full primary-button text-primary-cta-text cursor-pointer"
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterMinimal = ({
|
||||
brand,
|
||||
copyright,
|
||||
socialLinks,
|
||||
}: {
|
||||
brand: string;
|
||||
copyright: string;
|
||||
socialLinks?: SocialLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="relative w-full py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto px-10 pb-5 rounded-lg card">
|
||||
<AutoFillText className="font-semibold" paddingY="py-5">{brand}</AutoFillText>
|
||||
|
||||
<div className="h-px w-full mb-5 bg-foreground/50" />
|
||||
|
||||
<div className="flex flex-col gap-3 items-center justify-between md:flex-row">
|
||||
<span className="text-base opacity-75">{copyright}</span>
|
||||
{socialLinks && socialLinks.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
{socialLinks.map((link, index) => (
|
||||
<SocialLinkItem key={index} icon={link.icon} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterMinimal;
|
||||
82
src/components/sections/footer/FooterSimple.tsx
Normal file
82
src/components/sections/footer/FooterSimple.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: { label: string; href?: string; onClick?: () => void }[];
|
||||
};
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimple = ({
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="w-full py-15 mt-20 primary-button text-primary-cta-text">
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimple;
|
||||
82
src/components/sections/footer/FooterSimpleCard.tsx
Normal file
82
src/components/sections/footer/FooterSimpleCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimpleCard = ({
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="w-full py-20">
|
||||
<div className="w-content-width mx-auto p-10 rounded-lg card">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-foreground/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimpleCard;
|
||||
95
src/components/sections/footer/FooterSimpleMedia.tsx
Normal file
95
src/components/sections/footer/FooterSimpleMedia.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimpleMedia = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }) & {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="relative w-full mt-20 overflow-hidden">
|
||||
<div className="w-full aspect-square md:aspect-16/6 mask-fade-top-long">
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="w-full h-full object-cover rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-15 primary-button text-primary-cta-text">
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimpleMedia;
|
||||
120
src/components/sections/footer/FooterSimpleReveal.tsx
Normal file
120
src/components/sections/footer/FooterSimpleReveal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: { label: string; href?: string; onClick?: () => void }[];
|
||||
};
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimpleReveal = ({
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (footerRef.current) {
|
||||
setFooterHeight(footerRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
if (footerRef.current) {
|
||||
resizeObserver.observe(footerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative z-0 w-full mt-20"
|
||||
style={{
|
||||
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fixed bottom-0 w-full"
|
||||
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||
>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimpleReveal;
|
||||
72
src/components/sections/hero/HeroBillboard.tsx
Normal file
72
src/components/sections/hero/HeroBillboard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroBillboardProps = {
|
||||
tag?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboard = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 md:gap-15 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
{avatarsSrc && avatarsSrc.length > 0 ? (
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} className="mb-1" />
|
||||
) : tag ? (
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboard;
|
||||
52
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
52
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroBillboardBrandProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboardBrand = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardBrandProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-10 md:gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-end gap-5">
|
||||
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl leading-snug text-balance text-right"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardBrand;
|
||||
75
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
75
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroBillboardCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardCarouselProps) => {
|
||||
const duplicated = [...items, ...items, ...items, ...items];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((item, i) => (
|
||||
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCarousel;
|
||||
139
src/components/sections/hero/HeroBillboardCreator.tsx
Normal file
139
src/components/sections/hero/HeroBillboardCreator.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { motion } from "motion/react";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type CreatorVideo = {
|
||||
videoSrc: string;
|
||||
name: string;
|
||||
followers: string;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
type HeroBillboardCreatorProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
titleHighlight?: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
note: string;
|
||||
videos: CreatorVideo[];
|
||||
badgeText: string;
|
||||
};
|
||||
|
||||
const HeroBillboardCreator = ({
|
||||
tag,
|
||||
title,
|
||||
titleHighlight,
|
||||
description,
|
||||
primaryButton,
|
||||
note,
|
||||
videos,
|
||||
badgeText,
|
||||
}: HeroBillboardCreatorProps) => {
|
||||
const duplicated = [...videos, ...videos, ...videos, ...videos];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<motion.h1
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl font-semibold leading-[1.15] text-balance"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-20%" }}
|
||||
transition={{ staggerChildren: 0.04 }}
|
||||
>
|
||||
<motion.span
|
||||
className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent"
|
||||
variants={{ hidden: { opacity: 0, y: "50%" }, visible: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{title}{" "}
|
||||
{titleHighlight && (
|
||||
<span className="italic" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
{titleHighlight}
|
||||
</span>
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.h1>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
||||
<Check className="size-1/2 text-primary-cta-text" />
|
||||
</div>
|
||||
{note}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||
{duplicated.map((video, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative shrink-0 mr-3 xl:mr-4 2xl:mr-5 w-60 md:w-75 2xl:w-80 aspect-4/5 overflow-hidden rounded-lg"
|
||||
>
|
||||
<div className="absolute z-10 top-3 left-3 xl:top-4 xl:left-4 2xl:top-5 2xl:left-5 px-2 py-1 xl:px-2.5 xl:py-1.5 2xl:px-3 2xl:py-2 text-xs font-medium rounded-sm border border-background/30 bg-background/50 backdrop-blur-md">
|
||||
{badgeText}
|
||||
</div>
|
||||
<ImageOrVideo
|
||||
videoSrc={video.videoSrc}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-x-px -bottom-px h-1/3 bg-background-accent/50 backdrop-blur-xl"
|
||||
style={{ maskImage: "linear-gradient(to bottom, transparent, black 60%)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute flex items-center inset-x-3 bottom-3 xl:inset-x-4 xl:bottom-4 2xl:inset-x-5 2xl:bottom-5 gap-2 xl:gap-2.5 2xl:gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={video.imageSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="flex items-center gap-1 text-base text-background font-semibold leading-snug truncate">
|
||||
{video.name}
|
||||
<img src="https://storage.googleapis.com/webild/default/templates/ai-ugc/verified-badge.webp" alt="Verified" className="shrink-0 h-[calc(var(--text-base)*1.25)] w-auto" />
|
||||
</span>
|
||||
<span className="text-base text-background/75 leading-snug truncate">{video.followers}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCreator;
|
||||
105
src/components/sections/hero/HeroBillboardFeatures.tsx
Normal file
105
src/components/sections/hero/HeroBillboardFeatures.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import ActiveBadge from "@/components/ui/ActiveBadge";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type FeatureItem = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type HeroBillboardFeaturesProps = {
|
||||
badge: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
features: FeatureItem[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroBillboardFeatures = ({
|
||||
badge,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
features,
|
||||
}: HeroBillboardFeaturesProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (features.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % features.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [features.length]);
|
||||
|
||||
const feature = features[currentIndex];
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<ActiveBadge text={badge} className="mb-1" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute top-4 right-4 xl:top-6 xl:right-6 2xl:top-8 2xl:right-8 max-w-xs p-2 xl:p-3 2xl:p-4 card rounded flex flex-col gap-2"
|
||||
>
|
||||
<FeatureIcon className="size-5 text-accent mb-0.5" strokeWidth={1.5} />
|
||||
<p className="text-base font-medium leading-snug">{feature.title}</p>
|
||||
<p className="text-sm text-foreground/75 leading-snug">{feature.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardFeatures;
|
||||
180
src/components/sections/hero/HeroBillboardFloatingCards.tsx
Normal file
180
src/components/sections/hero/HeroBillboardFloatingCards.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useRef } from "react";
|
||||
import { useScroll, useTransform, motion } from "motion/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FloatingCardPosition = "top-left" | "top-right" | "middle-left" | "middle-right";
|
||||
|
||||
type HeroBillboardFloatingCardsProps = {
|
||||
avatarsSrc: string[];
|
||||
avatarsLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
note?: string;
|
||||
floatingCardsSrc: [string, string, string, string];
|
||||
logosSrc?: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const POSITIONS: FloatingCardPosition[] = ["top-left", "top-right", "middle-left", "middle-right"];
|
||||
|
||||
const FLOATING_CARD_CONFIG: Record<FloatingCardPosition, {
|
||||
position: string;
|
||||
rotation: string;
|
||||
size: string;
|
||||
animation: { duration: number; delay: number; yOffset: number; entryDelay: number };
|
||||
}> = {
|
||||
"top-left": {
|
||||
position: "top-8 left-0",
|
||||
rotation: "-rotate-8",
|
||||
size: "size-20 xl:size-22 2xl:size-24",
|
||||
animation: { duration: 4, delay: 0, yOffset: -8, entryDelay: 0.3 },
|
||||
},
|
||||
"top-right": {
|
||||
position: "top-4 right-4",
|
||||
rotation: "rotate-10",
|
||||
size: "size-18 xl:size-20 2xl:size-22",
|
||||
animation: { duration: 5, delay: 1, yOffset: -10, entryDelay: 0.5 },
|
||||
},
|
||||
"middle-left": {
|
||||
position: "top-1/2 left-2",
|
||||
rotation: "rotate-6",
|
||||
size: "size-18 xl:size-20 2xl:size-22",
|
||||
animation: { duration: 4.5, delay: 0.5, yOffset: -9, entryDelay: 0.7 },
|
||||
},
|
||||
"middle-right": {
|
||||
position: "top-1/2 right-0",
|
||||
rotation: "-rotate-6",
|
||||
size: "size-20 xl:size-22 2xl:size-24",
|
||||
animation: { duration: 3.8, delay: 1.5, yOffset: -8, entryDelay: 0.9 },
|
||||
},
|
||||
};
|
||||
|
||||
const HeroBillboardFloatingCards = ({
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
note,
|
||||
floatingCardsSrc,
|
||||
logosSrc,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardFloatingCardsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: containerRef });
|
||||
|
||||
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative">
|
||||
<HeroBackgroundSlot />
|
||||
<div ref={containerRef} className="pt-25 pb-20 md:pt-30 perspective-distant">
|
||||
<div className="relative w-content-width mx-auto">
|
||||
{POSITIONS.map((position, index) => {
|
||||
const config = FLOATING_CARD_CONFIG[position];
|
||||
const src = floatingCardsSrc[index];
|
||||
if (!src) return null;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={cls("absolute z-10 hidden md:block", config.position)}
|
||||
animate={{ y: [0, config.animation.yOffset, 0] }}
|
||||
transition={{ duration: config.animation.duration, repeat: Infinity, ease: "easeInOut", delay: config.animation.delay }}
|
||||
>
|
||||
<motion.div
|
||||
className={cls("p-2 card rounded-2xl overflow-hidden", config.size, config.rotation)}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: config.animation.entryDelay }}
|
||||
>
|
||||
<img src={src} alt="" className="w-full h-full object-contain rounded-xl" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-col items-center gap-3 md:max-w-8/10 mx-auto text-center">
|
||||
<div className="p-0.5 pr-3 mb-1 card rounded-full">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} />
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<motion.div
|
||||
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
||||
<Check className="size-1/2 text-primary-cta-text" />
|
||||
</div>
|
||||
{note}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{ rotateX: rotate, scale }}
|
||||
className="w-content-width mx-auto mt-5 2xl:mt-2 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||
</motion.div>
|
||||
|
||||
{logosSrc && logosSrc.length > 0 && (
|
||||
<ScrollReveal variant="slide-up" className="w-content-width mx-auto mt-2 xl:mt-4 2xl:mt-6 overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
|
||||
{[...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc].map((logo, index) => (
|
||||
<div key={index} className="shrink-0 mx-1 xl:mx-2 2xl:mx-3 p-3 rounded card">
|
||||
<img src={logo} alt="" className="h-8 w-auto object-contain rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardFloatingCards;
|
||||
82
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
82
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useRef } from "react";
|
||||
import { useScroll, useTransform, motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroBillboardScrollProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboardScroll = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardScrollProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: containerRef });
|
||||
|
||||
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative">
|
||||
<HeroBackgroundSlot />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pt-25 pb-20 md:pt-30 perspective-distant"
|
||||
>
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{ rotateX: rotate, scale }}
|
||||
className="w-content-width mx-auto mt-12 2xl:mt-8 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardScroll;
|
||||
126
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
126
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroBillboardTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroBillboardTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroBillboardTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-12 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute bottom-4 left-4 right-4 xl:left-6 xl:bottom-6 xl:right-auto 2xl:left-8 2xl:bottom-8 max-w-sm p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardTestimonial;
|
||||
61
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
61
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import TiltedCarousel from "@/components/ui/TiltedCarousel";
|
||||
|
||||
type HeroBillboardTiltedCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardTiltedCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardTiltedCarouselProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TiltedCarousel items={items} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardTiltedCarousel;
|
||||
65
src/components/sections/hero/HeroBrand.tsx
Normal file
65
src/components/sections/hero/HeroBrand.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
|
||||
type HeroBrandProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBrand = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBrandProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-primary-cta-text leading-snug"
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoFillText className="font-semibold text-primary-cta-text">{brand}</AutoFillText>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBrand;
|
||||
109
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
109
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type HeroBrandCarouselProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const INTERVAL = 4000;
|
||||
|
||||
const HeroBrandCarousel = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBrandCarouselProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % items.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, items.length]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"absolute inset-0 transition-opacity duration-500",
|
||||
currentIndex === index ? "opacity-100 z-1" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
aria-hidden={currentIndex !== index}
|
||||
>
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-primary-cta-text leading-snug"
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoFillText className="font-semibold text-primary-cta-text">{brand}</AutoFillText>
|
||||
|
||||
<div className="flex gap-3 pb-5">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="relative h-1 w-full rounded overflow-hidden bg-primary-cta-text/20 cursor-pointer"
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
aria-label="Slide"
|
||||
aria-current={currentIndex === index}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-0 bg-primary-cta-text rounded origin-left",
|
||||
currentIndex === index ? "animate-progress" : (index < currentIndex ? "scale-x-100" : "scale-x-0")
|
||||
)}
|
||||
style={{ animationDuration: `${INTERVAL}ms` }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBrandCarousel;
|
||||
80
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
80
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroCenteredLogosProps = {
|
||||
avatarsSrc: string[];
|
||||
avatarText: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
logos: string[];
|
||||
hideMedia?: boolean;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroCenteredLogos = ({
|
||||
avatarsSrc,
|
||||
avatarText,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
logos,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
hideMedia = false,
|
||||
}: HeroCenteredLogosProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative h-svh flex flex-col mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
{!hideMedia && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover" />
|
||||
<div className="absolute inset-0 bg-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 pt-8 w-content-width mx-auto text-center">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarText} size="lg" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-8 overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
|
||||
{[...logos, ...logos, ...logos, ...logos].map((logo, index) => (
|
||||
<div key={index} className="shrink-0 mx-3 px-4 py-2 card rounded">
|
||||
<span className="text-xl font-semibold whitespace-nowrap text-foreground/75">{logo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroCenteredLogos;
|
||||
83
src/components/sections/hero/HeroOverlay.tsx
Normal file
83
src/components/sections/hero/HeroOverlay.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroOverlayProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroOverlay = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
}: HeroOverlayProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-[150vw] h-[150vw] left-0 bottom-0 -translate-x-1/2 translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-10 md:pb-25">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-primary-cta-text text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl text-primary-cta-text leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{avatarsSrc && avatarsSrc.length > 0 && (
|
||||
<div className="mt-3 md:mt-4">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlay;
|
||||
98
src/components/sections/hero/HeroOverlayMarquee.tsx
Normal file
98
src/components/sections/hero/HeroOverlayMarquee.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroOverlayMarqueeProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatarsSrc?: string[];
|
||||
avatarsLabel?: string;
|
||||
items: { text: string; icon: LucideIcon }[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroOverlayMarquee = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
avatarsSrc,
|
||||
avatarsLabel,
|
||||
items,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroOverlayMarqueeProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative overflow-hidden flex flex-col justify-between mb-20 w-full h-svh"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 object-cover w-full h-full rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 left-0 top-0 w-[150vw] h-[150vw] -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 mx-auto pt-35 w-content-width">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl leading-snug text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{avatarsSrc && avatarsSrc.length > 0 && (
|
||||
<div className="mt-3 md:mt-4">
|
||||
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 overflow-hidden mx-auto pb-8 w-content-width mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
|
||||
{[...items, ...items, ...items, ...items].map((item, index) => (
|
||||
<div key={index} className="flex items-center shrink-0 gap-1 mx-3 pl-2 pr-4 py-2 card rounded">
|
||||
<item.icon className="h-(--text-base) text-foreground" />
|
||||
<span className="whitespace-nowrap text-base font-medium text-foreground">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlayMarquee;
|
||||
135
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
135
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroOverlayTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroOverlayTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroOverlayTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-start mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pt-35">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-primary-cta-text text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl text-primary-cta-text leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute z-10 bottom-4 left-4 right-4 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5 md:left-auto md:bottom-6 md:right-6 md:max-w-25/100 2xl:max-w-2/10"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlayTestimonial;
|
||||
65
src/components/sections/hero/HeroSplit.tsx
Normal file
65
src/components/sections/hero/HeroSplit.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroSplitProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroSplitProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplit;
|
||||
131
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
131
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type KpiItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type HeroSplitKpiProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
kpis: [KpiItem, KpiItem, KpiItem];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const KPI_POSITIONS = ["top-[5%] left-0", "top-[40%] right-0", "bottom-[5%] left-[5%]"];
|
||||
|
||||
const HeroSplitKpi = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
kpis,
|
||||
}: HeroSplitKpiProps) => {
|
||||
const kpiRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth <= 768) return;
|
||||
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
const offsets = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
|
||||
const multipliers = [-0.25, -0.5, 0.25];
|
||||
let animationId: number;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouseX = (e.clientX / window.innerWidth) * 100 - 50;
|
||||
mouseY = (e.clientY / window.innerHeight) * 100 - 50;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
offsets.forEach((offset, i) => {
|
||||
offset.x += ((mouseX * multipliers[i]) - offset.x) * 0.025;
|
||||
offset.y += ((mouseY * multipliers[i]) - offset.y) * 0.025;
|
||||
|
||||
const el = kpiRefs.current[i];
|
||||
if (el) el.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
|
||||
});
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full h-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden scale-85">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
|
||||
{kpis.map((kpi, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
ref={(el) => { kpiRefs.current[index] = el; }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut", delay: 0.4 + index * 0.1 }}
|
||||
className={cls(
|
||||
"absolute flex flex-col items-center p-3 xl:p-4 2xl:p-5 card backdrop-blur-sm rounded",
|
||||
KPI_POSITIONS[index]
|
||||
)}
|
||||
>
|
||||
<p className="text-2xl md:text-4xl text-foreground font-medium">{kpi.value}</p>
|
||||
<p className="text-sm md:text-base text-foreground/75">{kpi.label}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitKpi;
|
||||
72
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
72
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroSplitMediaGridProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: [
|
||||
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never },
|
||||
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }
|
||||
];
|
||||
};
|
||||
|
||||
const HeroSplitMediaGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroSplitMediaGridProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full md:w-1/2 grid grid-cols-2 gap-2 xl:gap-3 2xl:gap-4">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="h-80 md:h-[55vh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitMediaGrid;
|
||||
128
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
128
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroSplitTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroSplitTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroSplitTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute bottom-4 left-4 right-4 xl:bottom-6 xl:right-6 2xl:bottom-8 2xl:right-8 md:left-auto md:max-w-5/10 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
|
||||
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitTestimonial;
|
||||
87
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
87
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroSplitVerticalMarqueeProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroSplitVerticalMarquee = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
leftItems,
|
||||
rightItems,
|
||||
}: HeroSplitVerticalMarqueeProps) => {
|
||||
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
|
||||
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-2 xl:gap-3 2xl:gap-4 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden mask-fade-y-medium">
|
||||
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical">
|
||||
{duplicatedLeft.map((item, index) => (
|
||||
<div key={index} className="shrink-0 aspect-square p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden mask-fade-y-medium">
|
||||
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical-reverse">
|
||||
{duplicatedRight.map((item, index) => (
|
||||
<div key={index} className="shrink-0 aspect-square p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitVerticalMarquee;
|
||||
95
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
95
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface HeroTiltedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
}
|
||||
|
||||
const HeroTiltedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroTiltedCardsProps) => {
|
||||
const marqueeItems = [...items, ...items];
|
||||
const galleryStyles = [
|
||||
"-rotate-6 z-10 -translate-y-5",
|
||||
"rotate-6 z-20 translate-y-5 -ml-15",
|
||||
"-rotate-6 z-30 -translate-y-5 -ml-15",
|
||||
"rotate-6 z-40 translate-y-5 -ml-15",
|
||||
"-rotate-6 z-50 -translate-y-5 -ml-15",
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-12 md:gap-15 w-full md:w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="block md:hidden w-full overflow-hidden mask-padding-x">
|
||||
<div className="flex w-max animate-marquee-horizontal">
|
||||
{marqueeItems.map((item, index) => (
|
||||
<div key={index} className="shrink-0 w-[50vw] mr-5 aspect-4/5 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="hidden md:flex justify-center items-center w-full">
|
||||
<div className="flex items-center justify-center">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"relative w-[23%] aspect-4/5 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden shadow-lg transition-transform duration-500 ease-out hover:scale-110",
|
||||
galleryStyles[index]
|
||||
)}
|
||||
>
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroTiltedCards;
|
||||
146
src/components/sections/hero/HeroVideoExpand.tsx
Normal file
146
src/components/sections/hero/HeroVideoExpand.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
const StaggerText = ({ text }: { text: string }) => (
|
||||
<span className="truncate overflow-hidden">
|
||||
{[...text].map((char, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block transition-transform duration-400 ease-out md:group-hover:-translate-y-[1.25em]"
|
||||
style={{ textShadow: "0 1.25em currentColor", transitionDelay: `${index * 0.01}s`, whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
type HeroVideoExpandProps = {
|
||||
title: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
onComplete?: () => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroVideoExpand = ({
|
||||
title,
|
||||
videoSrc,
|
||||
imageSrc,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onComplete,
|
||||
}: HeroVideoExpandProps) => {
|
||||
const [showLoader, setShowLoader] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const handlePrimaryClick = useButtonClick(primaryButton.href);
|
||||
const handleSecondaryClick = useButtonClick(secondaryButton.href);
|
||||
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionRef,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const videoY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
|
||||
const videoScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
|
||||
|
||||
useEffect(() => {
|
||||
const expandTimer = setTimeout(() => setExpanded(true), 600);
|
||||
const hideTimer = setTimeout(() => {
|
||||
setShowLoader(false);
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => {
|
||||
clearTimeout(expandTimer);
|
||||
clearTimeout(hideTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{showLoader && (
|
||||
<motion.div
|
||||
key="loader"
|
||||
className="fixed inset-0 z-100 bg-background"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0, clipPath: "inset(25% 20% 25% 20% round 24px)" }}
|
||||
animate={
|
||||
expanded
|
||||
? { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }
|
||||
: { opacity: 1, clipPath: "inset(25% 20% 25% 20% round 24px)" }
|
||||
}
|
||||
transition={{ duration: expanded ? 1.4 : 1.2, ease: [0.76, 0, 0.24, 1] }}
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<section ref={sectionRef} aria-label="Hero section" className="relative w-full h-svh overflow-hidden mb-20">
|
||||
<motion.div className="absolute inset-0" style={{ y: videoY, scale: videoScale }}>
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="absolute inset-0 bg-linear-to-t from-background/60 via-transparent to-background/0" />
|
||||
|
||||
<div className="absolute inset-0 z-10 flex flex-col justify-end pb-8 md:pb-12 xl:pb-16 2xl:pb-20">
|
||||
<div className="w-content-width mx-auto flex flex-col md:flex-row md:items-end md:justify-between gap-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
className="w-full"
|
||||
>
|
||||
<AutoFillText className="font-medium text-white" paddingY="0">{title}</AutoFillText>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 1.2, delay: 0.1, ease: "easeOut" }}
|
||||
className="w-1/2 md:w-auto"
|
||||
>
|
||||
<a
|
||||
href={primaryButton.href}
|
||||
onClick={handlePrimaryClick}
|
||||
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer primary-button text-primary-cta-text"
|
||||
>
|
||||
<StaggerText text={primaryButton.text} />
|
||||
</a>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 1.2, delay: 0.2, ease: "easeOut" }}
|
||||
className="w-1/2 md:w-auto"
|
||||
>
|
||||
<a
|
||||
href={secondaryButton.href}
|
||||
onClick={handleSecondaryClick}
|
||||
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer secondary-button text-secondary-cta-text"
|
||||
>
|
||||
<StaggerText text={secondaryButton.text} />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroVideoExpand;
|
||||
283
src/components/sections/hero/HeroWorkScrollStack.tsx
Normal file
283
src/components/sections/hero/HeroWorkScrollStack.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface HeroWorkScrollStackProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
titleHighlight: string;
|
||||
description: string;
|
||||
descriptionMuted: string;
|
||||
primaryButton: { text: string; href: string; avatarSrc: string; avatarLabel: string };
|
||||
sectionTag: string;
|
||||
sectionTitle: string;
|
||||
sectionDescription: string;
|
||||
items: [
|
||||
{ title: string; description: string; imageSrc: string; tag: string },
|
||||
{ title: string; description: string; imageSrc: string; tag: string },
|
||||
{ title: string; description: string; imageSrc: string; tag: string }
|
||||
];
|
||||
secondaryButton?: { text: string; href: string };
|
||||
heroAnimationDelay?: number;
|
||||
}
|
||||
|
||||
const HeroWorkScrollStack = ({
|
||||
tag,
|
||||
title,
|
||||
titleHighlight,
|
||||
description,
|
||||
descriptionMuted,
|
||||
primaryButton,
|
||||
sectionTag,
|
||||
sectionTitle,
|
||||
sectionDescription,
|
||||
items,
|
||||
secondaryButton,
|
||||
heroAnimationDelay,
|
||||
}: HeroWorkScrollStackProps) => {
|
||||
const animationRef = useRef<HTMLDivElement>(null);
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const card1Ref = useRef<HTMLDivElement>(null);
|
||||
const card2Ref = useRef<HTMLDivElement>(null);
|
||||
const card3Ref = useRef<HTMLDivElement>(null);
|
||||
const handlePrimaryClick = useButtonClick(primaryButton.href);
|
||||
const handleSecondaryClick = useButtonClick(secondaryButton?.href || "#");
|
||||
|
||||
useEffect(() => {
|
||||
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const cardRefs = [card1Ref.current, card2Ref.current, card3Ref.current];
|
||||
const placeholder = placeholderRef.current;
|
||||
if (!placeholder) return;
|
||||
|
||||
const placeholderRect = placeholder.getBoundingClientRect();
|
||||
const placeholderCenterY = placeholderRect.top + placeholderRect.height / 2;
|
||||
|
||||
if (isDesktop) {
|
||||
// DESKTOP: Scrub animation tied to scroll position
|
||||
const xOffsets = ["32rem", "14.5rem", "-1.8rem"];
|
||||
const yAdjustments = [0, -48, 0];
|
||||
const rotations = [-5, 0, 5];
|
||||
const scales = [1.35, 1.3, 1.25];
|
||||
const zIndexes = [30, 20, 10];
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: animationRef.current,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
|
||||
cardRefs.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||||
const yOffset = placeholderCenterY - cardCenterY;
|
||||
|
||||
gsap.set(card, {
|
||||
x: xOffsets[i],
|
||||
y: yOffset + yAdjustments[i],
|
||||
rotation: rotations[i],
|
||||
scale: scales[i],
|
||||
zIndex: zIndexes[i],
|
||||
willChange: "transform",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
tl.to(card, { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.4, ease: "none" }, 0);
|
||||
tl.to(card, { zIndex: 1, duration: 0.1, ease: "none" }, 0.3);
|
||||
});
|
||||
} else {
|
||||
// MOBILE: Toggle animation - play/reverse on scroll
|
||||
const xOffsets = ["2.5rem", "0.5rem", "-1rem"];
|
||||
const yAdjustments = [-10, -30, 10];
|
||||
const rotations = [-5, 0, 5];
|
||||
const scales = [0.65, 0.7, 0.75];
|
||||
const zIndexes = [30, 20, 10];
|
||||
|
||||
cardRefs.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||||
const yOffset = placeholderCenterY - cardCenterY;
|
||||
|
||||
gsap.set(card, {
|
||||
x: xOffsets[i],
|
||||
y: yOffset + yAdjustments[i],
|
||||
rotation: rotations[i],
|
||||
scale: scales[i],
|
||||
zIndex: zIndexes[i],
|
||||
willChange: "transform",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
gsap.to(card, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
duration: 1.2,
|
||||
ease: "power2.inOut",
|
||||
scrollTrigger: {
|
||||
trigger: placeholder,
|
||||
start: "top 35%",
|
||||
toggleActions: "play none none reverse",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}, animationRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={animationRef}>
|
||||
<div id="hero" data-section="hero">
|
||||
<section aria-label="Hero section" className="relative h-fit md:h-svh pt-30 pb-20 md:py-0 flex items-center overflow-hidden md:overflow-visible">
|
||||
<HeroBackgroundSlot />
|
||||
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-full">
|
||||
<motion.div
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 1.8, ease: [0.16, 1, 0.3, 1], delay: heroAnimationDelay ?? 0 }}
|
||||
className="w-full md:w-[46%] flex flex-col items-center md:items-start gap-3"
|
||||
>
|
||||
<div className="card backdrop-blur flex items-center gap-2 px-3 py-1 rounded">
|
||||
<span className="size-2 rounded-full bg-green-500 animate-pulsate [--accent:#22c55e]" />
|
||||
<p className="text-sm leading-snug font-medium text-foreground">{tag}</p>
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-7xl 2xl:text-8xl font-medium leading-[1.05] tracking-tight text-center md:text-left">
|
||||
<span className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent">
|
||||
{title}{" "}
|
||||
<span className="font-bold">{titleHighlight}</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-base md:text-lg font-medium leading-snug text-center md:text-left max-w-[95%]">
|
||||
{description}{" "}
|
||||
<span className="text-foreground/50">{descriptionMuted}</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={primaryButton.href}
|
||||
onClick={handlePrimaryClick}
|
||||
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
||||
<img
|
||||
src={primaryButton.avatarSrc}
|
||||
className="w-9 h-9 rounded-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
|
||||
<div className="overflow-hidden flex items-center">
|
||||
<span className="text-primary-cta-text text-sm font-medium mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
|
||||
+
|
||||
</span>
|
||||
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
|
||||
<span className="w-9 h-9 rounded-full flex items-center justify-center">
|
||||
<span className="text-foreground text-xs font-bold">{primaryButton.avatarLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-medium whitespace-nowrap">{primaryButton.text}</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<div ref={placeholderRef} className="w-full md:w-[54%] relative h-80 md:h-96">
|
||||
<div className="absolute inset-0 card rounded-2xl md:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="work" data-section="work">
|
||||
<section aria-label="Work section" className="py-20 md:pt-0">
|
||||
<div className="flex flex-col gap-8 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{sectionTag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={sectionTitle}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={sectionDescription}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-5">
|
||||
{items.map((item, index) => {
|
||||
const cardRef = index === 0 ? card1Ref : index === 1 ? card2Ref : card3Ref;
|
||||
return (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-4 2xl:gap-5">
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="aspect-4/3 rounded-2xl shadow-2xl relative card p-2 xl:p-3 2xl:p-4"
|
||||
>
|
||||
<div className="w-full h-full rounded-xl overflow-hidden relative">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} className="w-full h-full object-cover" />
|
||||
<span className="absolute bottom-2 left-2 xl:bottom-3 xl:left-3 2xl:bottom-4 2xl:left-4 px-3 py-1.5 text-xs font-medium text-primary-cta-text rounded-full backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20">
|
||||
{item.tag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg md:text-xl lg:text-2xl leading-snug">
|
||||
<span className="font-semibold text-foreground">{item.title}. </span>
|
||||
<span className="text-foreground/50">{item.description}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{secondaryButton && (
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={secondaryButton.href}
|
||||
onClick={handleSecondaryClick}
|
||||
className="group flex items-center gap-2 px-6 py-3 text-base font-medium rounded-full secondary-button text-secondary-cta-text transition-all duration-300"
|
||||
>
|
||||
<span>{secondaryButton.text}</span>
|
||||
<ArrowRight className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroWorkScrollStack;
|
||||
71
src/components/sections/legal/PolicyContent.tsx
Normal file
71
src/components/sections/legal/PolicyContent.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type ContentItem =
|
||||
| { type: "paragraph"; text: string }
|
||||
| { type: "list"; items: string[] }
|
||||
| { type: "numbered-list"; items: string[] };
|
||||
|
||||
type ContentSection = {
|
||||
heading: string;
|
||||
content: ContentItem[];
|
||||
};
|
||||
|
||||
const PolicyContent = ({
|
||||
title,
|
||||
subtitle,
|
||||
sections,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sections: ContentSection[];
|
||||
}) => {
|
||||
return (
|
||||
<article aria-label="Policy content" className="w-content-width mx-auto pt-40 pb-20">
|
||||
<div className="md:max-w-1/2 mx-auto flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-3xl md:text-4xl font-semibold leading-snug">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm opacity-50">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20" />
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{sections.map((section) => (
|
||||
<section key={section.heading} className="flex flex-col gap-3">
|
||||
<h2 className="text-xl md:text-2xl font-semibold leading-snug">{section.heading}</h2>
|
||||
{section.content.map((item, i) => {
|
||||
if (item.type === "paragraph") {
|
||||
return (
|
||||
<p key={i} className="text-sm md:text-base opacity-75 leading-relaxed">
|
||||
{item.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const ListTag = item.type === "numbered-list" ? "ol" : "ul";
|
||||
|
||||
return (
|
||||
<ListTag
|
||||
key={i}
|
||||
className={cls(
|
||||
"flex flex-col gap-3 pl-5 text-sm md:text-base opacity-75 leading-relaxed",
|
||||
item.type === "numbered-list" ? "list-decimal" : "list-disc"
|
||||
)}
|
||||
>
|
||||
{item.items.map((li, j) => (
|
||||
<li key={j}>{li}</li>
|
||||
))}
|
||||
</ListTag>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyContent;
|
||||
86
src/components/sections/metrics/MetricsFeatureCards.tsx
Normal file
86
src/components/sections/metrics/MetricsFeatureCards.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
title: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const MetricsFeatureCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.value} className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 h-full card rounded">
|
||||
<div className="flex flex-col gap-0 min-w-0">
|
||||
<span className="text-8xl md:text-7xl font-semibold leading-none truncate">{metric.value}</span>
|
||||
<span className="text-xl truncate">{metric.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-5 border-t border-foreground/5">
|
||||
{metric.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm leading-snug">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsFeatureCards;
|
||||
82
src/components/sections/metrics/MetricsIconCards.tsx
Normal file
82
src/components/sections/metrics/MetricsIconCards.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Metric = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const MetricsIconCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{metrics.map((metric) => {
|
||||
const IconComponent = resolveIcon(metric.icon);
|
||||
return (
|
||||
<div key={metric.value} className="flex flex-col items-center justify-center gap-3 xl:gap-3.5 2xl:gap-4 p-6 xl:p-7 2xl:p-8 min-h-70 xl:min-h-80 2xl:min-h-90 h-full card rounded">
|
||||
<div className="flex items-center justify-center gap-2 min-w-0">
|
||||
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
|
||||
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-xl truncate min-w-0">{metric.title}</span>
|
||||
</div>
|
||||
<span className="text-9xl md:text-8xl font-semibold leading-none truncate">{metric.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsIconCards;
|
||||
100
src/components/sections/metrics/MetricsMediaCards.tsx
Normal file
100
src/components/sections/metrics/MetricsMediaCards.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const MetricsMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 w-content-width mx-auto">
|
||||
{metrics.map((metric, index) => {
|
||||
const isEven = index % 2 === 1;
|
||||
const isLast = index === metrics.length - 1;
|
||||
const isOddTotal = metrics.length % 2 !== 0;
|
||||
const shouldSpanFull = isLast && isOddTotal;
|
||||
|
||||
return (
|
||||
<ScrollReveal
|
||||
variant="slide-up"
|
||||
key={metric.value}
|
||||
className={cls("grid grid-cols-2 gap-5", shouldSpanFull && "md:col-span-2")}
|
||||
>
|
||||
<div className={cls(
|
||||
"flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded",
|
||||
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
|
||||
isEven && "order-2 md:order-1"
|
||||
)}>
|
||||
<span className="text-5xl md:text-6xl font-semibold leading-snug truncate">{metric.value}</span>
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
<span className="text-xl md:text-2xl font-semibold truncate">{metric.title}</span>
|
||||
<div className="w-full h-px bg-accent" />
|
||||
<p className="text-base leading-snug truncate">{metric.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cls(
|
||||
"rounded overflow-hidden",
|
||||
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
|
||||
isEven && "order-1 md:order-2"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={metric.imageSrc} videoSrc={metric.videoSrc} />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsMediaCards;
|
||||
71
src/components/sections/metrics/MetricsSimpleCards.tsx
Normal file
71
src/components/sections/metrics/MetricsSimpleCards.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const MetricsSimpleCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.value} className="flex flex-col justify-between gap-6 p-6 md:p-10 min-h-60 md:min-h-70 2xl:min-h-80 h-full card rounded">
|
||||
<span className="text-9xl md:text-8xl font-semibold leading-none truncate">{metric.value}</span>
|
||||
<p className="text-base leading-snug text-balance">{metric.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsSimpleCards;
|
||||
100
src/components/sections/pricing/PricingCenteredCards.tsx
Normal file
100
src/components/sections/pricing/PricingCenteredCards.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
};
|
||||
|
||||
const PricingCenteredCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.tag} className="flex flex-col items-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 h-full card rounded text-center">
|
||||
<div className="px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{plan.tag}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-5xl md:text-6xl font-semibold">{plan.price}</span>
|
||||
<span className="text-base font-medium">{plan.description}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
|
||||
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20" />
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base text-left">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingCenteredCards;
|
||||
104
src/components/sections/pricing/PricingHighlightedCards.tsx
Normal file
104
src/components/sections/pricing/PricingHighlightedCards.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
highlight?: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
};
|
||||
|
||||
const PricingHighlightedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.tag} className="flex flex-col h-full">
|
||||
<div className={cls("px-5 py-2 text-base", plan.highlight ? "text-center primary-button rounded-t text-primary-cta-text" : "invisible")}>
|
||||
{plan.highlight || "placeholder"}
|
||||
</div>
|
||||
|
||||
<div className={cls("flex flex-col items-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 flex-1 card text-center", plan.highlight ? "rounded-t-none rounded-b" : "rounded")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-5xl md:text-6xl font-semibold">{plan.price}</span>
|
||||
<span className="text-base font-medium">{plan.tag}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-foreground/20" />
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base text-left">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full mt-auto">
|
||||
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
|
||||
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingHighlightedCards;
|
||||
100
src/components/sections/pricing/PricingLayeredCards.tsx
Normal file
100
src/components/sections/pricing/PricingLayeredCards.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const PricingLayeredCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.tag} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||
<div className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 secondary-button rounded">
|
||||
<div className="px-3 py-1 w-fit text-sm card rounded">
|
||||
<p>{plan.tag}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-5xl md:text-6xl font-semibold">{plan.price}</span>
|
||||
<span className="text-base font-medium">{plan.description}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
|
||||
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-3 xl:p-3.5 2xl:p-4">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base leading-snug">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingLayeredCards;
|
||||
98
src/components/sections/pricing/PricingMediaCards.tsx
Normal file
98
src/components/sections/pricing/PricingMediaCards.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
primaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const PricingMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<ScrollReveal
|
||||
variant="slide-up"
|
||||
key={plan.tag}
|
||||
className="flex flex-col md:flex-row gap-6 md:gap-10 p-6 md:p-10 card rounded"
|
||||
>
|
||||
<div className="w-full md:w-1/2 aspect-square md:aspect-4/3 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={plan.imageSrc} videoSrc={plan.videoSrc} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-2 w-full md:w-1/2">
|
||||
<div className="px-3 py-1 mb-1 w-fit text-sm card rounded">
|
||||
<p>{plan.price}{plan.period}</p>
|
||||
</div>
|
||||
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{plan.tag}</h3>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-1">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base leading-snug">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-fit mt-2 md:mt-3" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingMediaCards;
|
||||
93
src/components/sections/pricing/PricingSimpleCards.tsx
Normal file
93
src/components/sections/pricing/PricingSimpleCards.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const PricingSimpleCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.tag} className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 h-full card rounded">
|
||||
<div className="px-3 py-1 text-sm card rounded w-fit">
|
||||
<p>{plan.tag}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-5xl md:text-6xl font-semibold">{plan.price}</span>
|
||||
<span className="text-base font-medium">{plan.description}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingSimpleCards;
|
||||
109
src/components/sections/pricing/PricingSplitCards.tsx
Normal file
109
src/components/sections/pricing/PricingSplitCards.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type PricingPlan = {
|
||||
tag: string;
|
||||
price: string;
|
||||
period: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
featuresTitle: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const PricingSplitCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
plans,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
plans: PricingPlan[];
|
||||
}) => (
|
||||
<section aria-label="Pricing section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<ScrollReveal
|
||||
variant="slide-up"
|
||||
key={plan.tag}
|
||||
className="flex flex-col md:flex-row gap-5 md:gap-12 p-5 md:p-12 card rounded"
|
||||
>
|
||||
<div className="flex flex-col gap-2 justify-between w-full md:w-1/2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-3 py-1 mb-2 w-fit text-sm card rounded">
|
||||
<p>{plan.tag}</p>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-5xl md:text-6xl font-semibold">{plan.price}</span>
|
||||
<span className="text-2xl font-medium">{plan.period}</span>
|
||||
</div>
|
||||
<p className="text-xl md:text-2xl leading-snug text-balance">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
|
||||
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20 md:hidden" />
|
||||
|
||||
<div className="flex flex-col gap-3 w-full md:w-1/2">
|
||||
<h3 className="text-xl font-medium truncate">{plan.featuresTitle}</h3>
|
||||
<div className="w-full h-px bg-foreground/5" />
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-base leading-snug">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PricingSplitCards;
|
||||
119
src/components/sections/product/ProductMediaCards.tsx
Normal file
119
src/components/sections/product/ProductMediaCards.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
|
||||
type ProductMediaCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
products?: {
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
onClick?: () => void;
|
||||
}[];
|
||||
};
|
||||
|
||||
const ProductMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
products: productsProp,
|
||||
}: ProductMediaCardsProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = isFromApi
|
||||
? fetchedProducts.map((p) => ({
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onProductClick,
|
||||
}))
|
||||
: productsProp;
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.name}
|
||||
onClick={product.onClick}
|
||||
className="group h-full flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 text-left card rounded cursor-pointer"
|
||||
>
|
||||
<div className="aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={product.imageSrc} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<h3 className="text-2xl font-semibold truncate">{product.name}</h3>
|
||||
<p className="text-base font-medium">{product.price}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center size-9 shrink-0 rounded primary-button">
|
||||
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductMediaCards;
|
||||
152
src/components/sections/product/ProductQuantityCards.tsx
Normal file
152
src/components/sections/product/ProductQuantityCards.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Minus, Loader2 } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
|
||||
type ProductQuantityCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
products?: {
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
onAddToCart?: (quantity: number) => void;
|
||||
}[];
|
||||
};
|
||||
|
||||
const ProductQuantityCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
products: productsProp,
|
||||
}: ProductQuantityCardsProps) => {
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = isFromApi
|
||||
? fetchedProducts.map((p) => ({
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
imageSrc: p.imageSrc,
|
||||
onAddToCart: undefined as ((quantity: number) => void) | undefined,
|
||||
}))
|
||||
: productsProp;
|
||||
|
||||
const getQuantity = (name: string) => quantities[name] || 1;
|
||||
|
||||
const handleIncrement = (name: string) => {
|
||||
setQuantities((prev) => ({ ...prev, [name]: (prev[name] || 1) + 1 }));
|
||||
};
|
||||
|
||||
const handleDecrement = (name: string) => {
|
||||
setQuantities((prev) => ({ ...prev, [name]: Math.max(1, (prev[name] || 1) - 1) }));
|
||||
};
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.name}
|
||||
className="h-full flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded"
|
||||
>
|
||||
<div className="aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={product.imageSrc} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-3 xl:p-3.5 2xl:p-4">
|
||||
<h3 className="text-2xl font-semibold truncate">{product.name}</h3>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleDecrement(product.name)}
|
||||
className="flex items-center justify-center size-9 rounded secondary-button cursor-pointer"
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
<span className="w-fit text-base text-center font-semibold">{getQuantity(product.name)}</span>
|
||||
|
||||
<button
|
||||
onClick={() => handleIncrement(product.name)}
|
||||
className="flex items-center justify-center size-9 rounded secondary-button cursor-pointer"
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => product.onAddToCart?.(getQuantity(product.name))}
|
||||
className="h-9 px-5 rounded primary-button text-base text-primary-cta-text font-medium cursor-pointer"
|
||||
>
|
||||
{product.price}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductQuantityCards;
|
||||
143
src/components/sections/product/ProductRatingCards.tsx
Normal file
143
src/components/sections/product/ProductRatingCards.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Star, Loader2 } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
|
||||
type ProductRatingCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
products?: {
|
||||
brand: string;
|
||||
name: string;
|
||||
price: string;
|
||||
rating: number;
|
||||
reviewCount: string;
|
||||
imageSrc: string;
|
||||
onClick?: () => void;
|
||||
}[];
|
||||
};
|
||||
|
||||
const ProductRatingCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
products: productsProp,
|
||||
}: ProductRatingCardsProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = isFromApi
|
||||
? fetchedProducts.map((p) => ({
|
||||
brand: p.brand || "",
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
rating: p.rating || 0,
|
||||
reviewCount: p.reviewCount || "0",
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onProductClick,
|
||||
}))
|
||||
: productsProp;
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.name}
|
||||
onClick={product.onClick}
|
||||
className="group h-full flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 text-left card rounded cursor-pointer"
|
||||
>
|
||||
<div className="aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={product.imageSrc} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p className="truncate">{product.brand}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-medium truncate">{product.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls(
|
||||
"size-5 text-accent",
|
||||
index < Math.floor(product.rating) ? "fill-accent" : "fill-transparent"
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-base">({product.reviewCount})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-2xl font-semibold mt-1">{product.price}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductRatingCards;
|
||||
127
src/components/sections/product/ProductVariantCards.tsx
Normal file
127
src/components/sections/product/ProductVariantCards.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
|
||||
type ProductVariantCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
products?: {
|
||||
name: string;
|
||||
variant: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
onClick?: () => void;
|
||||
}[];
|
||||
};
|
||||
|
||||
const ProductVariantCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
products: productsProp,
|
||||
}: ProductVariantCardsProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
const isFromApi = fetchedProducts.length > 0;
|
||||
const products = isFromApi
|
||||
? fetchedProducts.map((p) => ({
|
||||
name: p.name,
|
||||
variant: p.variant || "",
|
||||
price: p.price,
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onProductClick,
|
||||
}))
|
||||
: productsProp;
|
||||
|
||||
if (isLoading && !productsProp) {
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Products section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.name}
|
||||
onClick={product.onClick}
|
||||
className="group h-full flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 text-left card rounded cursor-pointer"
|
||||
>
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={product.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<div className="flex items-center justify-center size-12 rounded-full primary-button opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300">
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 p-3 xl:p-3.5 2xl:p-4">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<h3 className="text-2xl font-semibold truncate leading-snug text-balance">{product.name}</h3>
|
||||
<p className="text-base text-foreground/75 truncate">{product.variant}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xl font-medium shrink-0">{product.price}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductVariantCards;
|
||||
66
src/components/sections/social-proof/SocialProofMarquee.tsx
Normal file
66
src/components/sections/social-proof/SocialProofMarquee.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
const SocialProofMarquee = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
names,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
names: string[];
|
||||
}) => {
|
||||
return (
|
||||
<section aria-label="Social proof section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
|
||||
{[...names, ...names, ...names, ...names].map((name, index) => (
|
||||
<div key={index} className="shrink-0 mx-3 px-5 py-3 rounded card">
|
||||
<span className="text-2xl font-semibold whitespace-nowrap opacity-75">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialProofMarquee;
|
||||
108
src/components/sections/team/TeamDetailedCards.tsx
Normal file
108
src/components/sections/team/TeamDetailedCards.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type SocialLink = {
|
||||
icon: string | LucideIcon;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TeamMember = {
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
socialLinks: SocialLink[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const TeamDetailedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
members,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
members: TeamMember[];
|
||||
}) => (
|
||||
<section aria-label="Team section" className="py-20">
|
||||
<div className="flex flex-col gap-8 md:gap-10">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||
<p>{tag}</p>
|
||||
</div>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel >
|
||||
{members.map((member) => (
|
||||
<div key={member.name} className="relative aspect-4/5 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={member.imageSrc} videoSrc={member.videoSrc} />
|
||||
|
||||
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex flex-col gap-1 xl:gap-2 2xl:gap-3 p-4 xl:p-5 2xl:p-6 card backdrop-blur-sm rounded">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="text-2xl font-semibold leading-snug truncate">{member.name}</span>
|
||||
<div className="px-3 py-1 text-sm secondary-button text-secondary-cta-text rounded">
|
||||
<p className="truncate">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-base leading-snug">{member.description}</p>
|
||||
|
||||
<div className="flex gap-3 mt-1 md:mt-2">
|
||||
{member.socialLinks.map((link, index) => {
|
||||
const IconComponent = resolveIcon(link.icon);
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center size-9 primary-button rounded"
|
||||
>
|
||||
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default TeamDetailedCards;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user