Initial commit
This commit is contained in:
83
.claude/rules/accessibility.md
Normal file
83
.claude/rules/accessibility.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/components/**/*.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accessibility Patterns
|
||||||
|
|
||||||
|
## Interactive Components
|
||||||
|
|
||||||
|
### ariaLabel Prop
|
||||||
|
|
||||||
|
Always include `ariaLabel` prop with sensible fallback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Props {
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
<section aria-label={ariaLabel || "Default section label"}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disabled States
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Indicators
|
||||||
|
|
||||||
|
Custom focus indicators for consistent styling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button className="focus:outline-none focus:ring-2 focus:ring-foreground/50">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Media Components
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
Use `aria-hidden` for decorative images:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={imageAlt}
|
||||||
|
aria-hidden={imageAlt === ""}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Videos
|
||||||
|
|
||||||
|
Always include `videoAriaLabel` prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<video aria-label={videoAriaLabel || "Video content"}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic HTML
|
||||||
|
|
||||||
|
### Heading Hierarchy
|
||||||
|
|
||||||
|
- `h1` - Page title (one per page)
|
||||||
|
- `h2` - Section headings
|
||||||
|
- `h3` - Subsection headings
|
||||||
|
- `h4` - Card titles
|
||||||
|
|
||||||
|
### Semantic Elements
|
||||||
|
|
||||||
|
Use appropriate semantic elements:
|
||||||
|
|
||||||
|
- `<section>` - Thematic groupings of content
|
||||||
|
- `<nav>` - Navigation blocks
|
||||||
|
- `<article>` - Self-contained content
|
||||||
|
- `<aside>` - Tangentially related content
|
||||||
|
- `<main>` - Primary content area
|
||||||
|
- `<header>` / `<footer>` - Section headers/footers
|
||||||
95
.claude/rules/animations.md
Normal file
95
.claude/rules/animations.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/components/text/**/*.tsx"
|
||||||
|
- "src/components/hooks/**/*.ts"
|
||||||
|
- "src/components/sections/**/*.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Animation Patterns
|
||||||
|
|
||||||
|
## Button Animation Types
|
||||||
|
|
||||||
|
Apply to `tagAnimation` or `buttonAnimation` props:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ButtonAnimationType = "none" | "opacity" | "slide-up" | "blur-reveal";
|
||||||
|
```
|
||||||
|
|
||||||
|
- `none` - No animation
|
||||||
|
- `opacity` - Fade in
|
||||||
|
- `slide-up` - Fade + slide from bottom
|
||||||
|
- `blur-reveal` - Fade with blur clearing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Text Animation Types
|
||||||
|
|
||||||
|
Set via `defaultTextAnimation` in ThemeProvider or `type` prop in TextAnimation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AnimationType = "entrance-slide" | "reveal-blur" | "background-highlight";
|
||||||
|
```
|
||||||
|
|
||||||
|
- `entrance-slide` - Characters slide up from bottom
|
||||||
|
- `reveal-blur` - Text appears with blur clearing
|
||||||
|
- `background-highlight` - Text highlights from dim to full opacity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useButtonAnimation } from "@/components/hooks/useButtonAnimation";
|
||||||
|
|
||||||
|
// Animate children on scroll
|
||||||
|
const { containerRef } = useButtonAnimation({ animationType: "slide-up" });
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<button>Animates on scroll</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CardStack Animation Types
|
||||||
|
|
||||||
|
- `none` - No animation
|
||||||
|
- `opacity` - Fade in
|
||||||
|
- `slide-up` - Slide with stagger
|
||||||
|
- `scale-rotate` - Scale + rotate with stagger
|
||||||
|
- `blur-reveal` - Blur to clear with stagger
|
||||||
|
- `depth-3d` - 3D perspective (grid only, desktop)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GSAP Best Practices
|
||||||
|
|
||||||
|
### Plugin Registration
|
||||||
|
|
||||||
|
Register plugins at file level, not in useEffect:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { gsap } from "gsap";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup with gsap.context()
|
||||||
|
|
||||||
|
Always use gsap.context() for proper cleanup:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
gsap.to(".element", { opacity: 1 });
|
||||||
|
}, containerRef);
|
||||||
|
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Use `force3D: true` for GPU acceleration
|
||||||
|
- Prefer transform properties (x, y, scale, rotation)
|
||||||
|
- Consider disabling complex animations on mobile
|
||||||
159
.claude/rules/components.md
Normal file
159
.claude/rules/components.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/components/**/*.tsx"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component Patterns
|
||||||
|
|
||||||
|
## Naming Conventions (CRITICAL)
|
||||||
|
|
||||||
|
### Prop Naming
|
||||||
|
|
||||||
|
| Use | Don't Use |
|
||||||
|
|-----|-----------|
|
||||||
|
| `title` | `heading`, `headline` |
|
||||||
|
| `description` | `subtitle`, `text`, `content` |
|
||||||
|
| `text` (for buttons) | `title`, `label`, `buttonText` |
|
||||||
|
|
||||||
|
### className Prop Pattern
|
||||||
|
|
||||||
|
- `className` - Main wrapper element
|
||||||
|
- `containerClassName` - Inner container
|
||||||
|
- `[element]ClassName` - Specific elements (titleClassName, imageClassName)
|
||||||
|
|
||||||
|
### ButtonConfig
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CORRECT - variant controlled by ThemeProvider
|
||||||
|
{ text: "Get Started", href: "/start" }
|
||||||
|
|
||||||
|
// WRONG - never specify variant in config
|
||||||
|
{ text: "Get Started", variant: "primary" } // ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Composition Principle (CRITICAL)
|
||||||
|
|
||||||
|
When creating new components, **ALWAYS base them on existing similar components** to maintain consistency.
|
||||||
|
|
||||||
|
### The Rule
|
||||||
|
|
||||||
|
1. **Identify the base component** - Find an existing component with similar layout/purpose
|
||||||
|
2. **Identify feature components** - Find existing implementations of any features you're adding
|
||||||
|
3. **Confirm with user** - Propose which components you'll use as references before implementing
|
||||||
|
4. **Compose, don't reinvent** - Use patterns, code structure, and styling from the base components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical Base Components
|
||||||
|
|
||||||
|
### Hero Sections
|
||||||
|
| Component | Use As Base When |
|
||||||
|
|-----------|------------------|
|
||||||
|
| `HeroSplit` | Split layouts with media left/right positioning |
|
||||||
|
| `HeroBillboard` | Full-width centered layouts with media below |
|
||||||
|
| `HeroOverlay` | Media backgrounds with text overlay |
|
||||||
|
|
||||||
|
### Feature Sections
|
||||||
|
| Component | Use As Base When |
|
||||||
|
|-----------|------------------|
|
||||||
|
| `FeatureBento` | Complex card layouts with multiple variants |
|
||||||
|
| `FeatureCardOne` | Simple media + text card grids |
|
||||||
|
| `FeatureCardSix` | Sequential process/step displays |
|
||||||
|
|
||||||
|
### Testimonial Sections
|
||||||
|
| Component | Use As Base When |
|
||||||
|
|-----------|------------------|
|
||||||
|
| `TestimonialCardThirteen` | Card-based testimonials with ratings |
|
||||||
|
| `TestimonialCardTen` | Testimonials with associated media |
|
||||||
|
|
||||||
|
### Other Sections
|
||||||
|
| Component | Use As Base When |
|
||||||
|
|-----------|------------------|
|
||||||
|
| `PricingCardThree` | Comparison tables with feature lists |
|
||||||
|
| `TeamCardOne` | Image-first cards with overlay text |
|
||||||
|
| `ContactSplitForm` | Forms with media split layout |
|
||||||
|
| `FaqDouble` | Two-column accordion layouts |
|
||||||
|
| `FooterBase` | Simple column-based footers |
|
||||||
|
| `MetricCardOne` | Large number displays with gradient effects |
|
||||||
|
| `BlogCardOne` | Content cards with metadata and images |
|
||||||
|
| `SplitAbout` | Split layouts with bullet points |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical Shared Components
|
||||||
|
|
||||||
|
Always use these existing implementations rather than creating new ones:
|
||||||
|
|
||||||
|
| Component | Purpose | File |
|
||||||
|
|-----------|---------|------|
|
||||||
|
| `MediaContent` | Image/video display | `/src/components/shared/MediaContent.tsx` |
|
||||||
|
| `AvatarGroup` | Avatar displays with overflow | `/src/components/shared/AvatarGroup.tsx` |
|
||||||
|
| `TestimonialAuthor` | Author/attribution cards | `/src/components/shared/TestimonialAuthor.tsx` |
|
||||||
|
| `LogoMarquee` | Scrolling logo displays | `/src/components/shared/LogoMarquee.tsx` |
|
||||||
|
| `Tag` | Theme-aware badges | `/src/components/shared/Tag.tsx` |
|
||||||
|
| `Badge` | Simple primary badges | `/src/components/shared/Badge.tsx` |
|
||||||
|
| `CardStack` | Grid/carousel layouts | `/src/components/cardStack/CardStack.tsx` |
|
||||||
|
| `CardStackTextBox` | Section headers | `/src/components/cardStack/CardStackTextBox.tsx` |
|
||||||
|
| `TextAnimation` | Animated text | `/src/components/text/TextAnimation.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Component Usage
|
||||||
|
|
||||||
|
### TextBox
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import TextBox from "@/components/Textbox";
|
||||||
|
|
||||||
|
// Default layout - centered stack
|
||||||
|
<TextBox
|
||||||
|
title="Your Title"
|
||||||
|
description="Your description text"
|
||||||
|
tag="Optional Tag"
|
||||||
|
tagIcon={Sparkles}
|
||||||
|
buttons={[{ text: "Primary", href: "/action" }]}
|
||||||
|
center={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Layout options: "default" | "split" | "split-actions" | "split-description" | "inline-image"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
Buttons automatically get styling based on their index:
|
||||||
|
- **Index 0** = `primary-button` class with `text-primary-cta-text`
|
||||||
|
- **Index 1+** = `secondary-button` class with `text-secondary-cta-text`
|
||||||
|
|
||||||
|
### MediaContent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MediaContent from "@/components/shared/MediaContent";
|
||||||
|
|
||||||
|
<MediaContent
|
||||||
|
imageSrc="/image.jpg"
|
||||||
|
imageAlt="Description"
|
||||||
|
imageClassName="rounded-theme"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Video takes precedence over image when both provided
|
||||||
|
<MediaContent
|
||||||
|
videoSrc="/video.mp4"
|
||||||
|
videoAriaLabel="Video description"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### AnimationContainer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import AnimationContainer from "@/components/sections/AnimationContainer";
|
||||||
|
|
||||||
|
<AnimationContainer>
|
||||||
|
<div>Content animates in (fade + slide)</div>
|
||||||
|
</AnimationContainer>
|
||||||
|
|
||||||
|
<AnimationContainer animationType="fade">
|
||||||
|
<div>Fades in only</div>
|
||||||
|
</AnimationContainer>
|
||||||
|
```
|
||||||
123
.claude/rules/registry.md
Normal file
123
.claude/rules/registry.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "registry/**/*.json"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Registry Patterns
|
||||||
|
|
||||||
|
The registry system enables AI tooling and code generation for components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Files
|
||||||
|
|
||||||
|
| File | Purpose | When to Update |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `registry.json` | Full component configs with constraints | New component, prop changes |
|
||||||
|
| `registry/components/[Name].json` | Individual component config | New component, prop changes |
|
||||||
|
| `registry/schemas/[Name].schema.json` | Props schema for code gen | New component, prop changes |
|
||||||
|
| `registry/index.json` | Category/intent mapping | New component |
|
||||||
|
| `registry/intents.json` | Intent groupings | New component or intent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## registry/index.json Entry
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ComponentName": {
|
||||||
|
"category": "hero",
|
||||||
|
"intent": "hero with media",
|
||||||
|
"bestFor": ["landing pages", "product launches"],
|
||||||
|
"avoidWhen": ["simple text-only sections"],
|
||||||
|
"requires": ["title", "description", "background"],
|
||||||
|
"import": "@/components/sections/hero/ComponentName"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## registry/components/[Name].json Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "ComponentName",
|
||||||
|
"description": "Brief description of the component",
|
||||||
|
"details": "Detailed usage information",
|
||||||
|
"constraints": {
|
||||||
|
"textRules": {
|
||||||
|
"title": {
|
||||||
|
"required": true,
|
||||||
|
"example": "Example title text",
|
||||||
|
"minChars": 4,
|
||||||
|
"maxChars": 50
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"required": true,
|
||||||
|
"example": "Example description text",
|
||||||
|
"minChars": 20,
|
||||||
|
"maxChars": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"propsSchema": {
|
||||||
|
"title": "string",
|
||||||
|
"description": "string",
|
||||||
|
"optionalProp?": "string (default: 'value')"
|
||||||
|
},
|
||||||
|
"usageExample": "<ComponentName title=\"...\" />",
|
||||||
|
"do": ["Use for X", "Use for Y"],
|
||||||
|
"dont": ["Don't use for Z"],
|
||||||
|
"editRules": {
|
||||||
|
"textOnly": true,
|
||||||
|
"layoutLocked": true,
|
||||||
|
"styleLocked": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## registry/schemas/[Name].schema.json Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "ComponentName",
|
||||||
|
"propsSchema": {
|
||||||
|
"title": "string",
|
||||||
|
"description": "string",
|
||||||
|
"optionalProp?": "string (default: 'value')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props Schema Conventions
|
||||||
|
|
||||||
|
- Required props: `"propName": "type"`
|
||||||
|
- Optional props: `"propName?": "type (default: 'value')"`
|
||||||
|
- Arrays: `"items": "Array<{ name: string, value: string }>"`
|
||||||
|
- Complex types: Include description after type
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"description": "string",
|
||||||
|
"inputs": "Array<{ name: string, type: string, placeholder: string }> - Form input fields",
|
||||||
|
"mediaPosition?": "'left' | 'right' (default: 'right')",
|
||||||
|
"onSubmit?": "(data: Record<string, string>) => void"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keeping Registry in Sync
|
||||||
|
|
||||||
|
**Critical**: Registry must exactly match component props.
|
||||||
|
|
||||||
|
1. Optional props in component → `"prop?"` in schema
|
||||||
|
2. Default values in component → `(default: 'value')` in schema
|
||||||
|
3. All prop types must match TypeScript interface
|
||||||
188
.claude/rules/styling.md
Normal file
188
.claude/rules/styling.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/**/*.tsx"
|
||||||
|
- "src/**/*.css"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Styling Patterns
|
||||||
|
|
||||||
|
This codebase uses **custom-defined CSS variables** for all sizing, spacing, and dimensions. **DO NOT use default Tailwind classes** that are not defined in this system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NOT to Use
|
||||||
|
|
||||||
|
These default Tailwind classes are **NOT defined** and will not work:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DO NOT USE - undefined width classes
|
||||||
|
w-xs, w-sm, w-md, w-lg, w-xl, w-2xl, w-3xl, w-4xl, w-5xl, w-6xl, w-7xl
|
||||||
|
w-screen, w-min, w-max, w-fit (except these work)
|
||||||
|
w-96, w-80, w-72, w-64, w-56, w-48 (fixed pixel widths)
|
||||||
|
|
||||||
|
// DO NOT USE - undefined height classes
|
||||||
|
h-screen (use h-svh instead), h-min, h-max
|
||||||
|
h-96, h-80, h-72, h-64, h-56, h-48 (fixed pixel widths except defined ones)
|
||||||
|
|
||||||
|
// DO NOT USE - undefined spacing beyond 8
|
||||||
|
p-9, p-10, p-11, p-12, p-14, p-16, p-20, p-24, etc.
|
||||||
|
m-9, m-10, m-11, m-12, m-14, m-16, m-20, m-24, etc.
|
||||||
|
gap-9, gap-10, gap-11, gap-12, etc.
|
||||||
|
|
||||||
|
// DO NOT USE - undefined border radius
|
||||||
|
rounded-2xl, rounded-3xl (use rounded-theme or rounded-theme-capped)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What TO Use
|
||||||
|
|
||||||
|
| Category | Use These | Don't Use |
|
||||||
|
|----------|-----------|-----------|
|
||||||
|
| **Width** | `w-5` to `w-100`, `w-content-width`, fractions | `w-xs`, `w-md`, `w-lg`, `w-96` |
|
||||||
|
| **Height** | `h-4` to `h-12`, `h-30`, `h-90` to `h-150`, `h-svh` | `h-screen`, `h-96`, `h-80` |
|
||||||
|
| **Padding** | `p-1` to `p-8` | `p-9`, `p-10`, `p-12`, `p-16` |
|
||||||
|
| **Margin** | `m-1` to `m-8` | `m-9`, `m-10`, `m-12`, `m-16` |
|
||||||
|
| **Gap** | `gap-1` to `gap-8` | `gap-9`, `gap-10`, `gap-12` |
|
||||||
|
| **Text** | `text-2xs` to `text-9xl` | (all defined) |
|
||||||
|
| **Radius** | `rounded-theme`, `rounded-theme-capped` | `rounded-2xl`, `rounded-3xl` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale (1-8 ONLY)
|
||||||
|
|
||||||
|
All spacing uses VW-based fluid scaling. **Only values 1-8 are defined:**
|
||||||
|
- `p-1` to `p-8`, `m-1` to `m-8`, `gap-1` to `gap-8`
|
||||||
|
- Directional: `px-4`, `py-6`, `mx-2`, `my-4`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Width (CRITICAL FOR SECTIONS)
|
||||||
|
|
||||||
|
**`w-content-width`** is the most important width class - use it for all section content containers:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ALWAYS use w-content-width for section containers
|
||||||
|
<section className="relative w-full">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
{/* Section content goes here */}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="w-content-width" /> // Main content width
|
||||||
|
<div className="w-content-width-expanded" /> // Expanded (for carousels)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Width Scale
|
||||||
|
|
||||||
|
| Pattern | Values | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| `w-5` to `w-100` | Increments of 5 (5vw to 100vw) | Main width classes |
|
||||||
|
| `w-7_5`, `w-12_5`, etc. | Increments of 2.5 | Half-step widths |
|
||||||
|
| `w-carousel-item-3`, `w-carousel-item-4` | Carousel widths | For carousel items |
|
||||||
|
| `w-full`, `w-1/2`, `w-1/3`, etc. | Standard fractions | Tailwind defaults work |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Height Scale
|
||||||
|
|
||||||
|
Heights use standard rem on desktop, but become vw-based on mobile (< 768px).
|
||||||
|
|
||||||
|
- **Standard**: `h-4` to `h-12` (1rem to 3rem)
|
||||||
|
- **Large**: `h-30`, `h-90` to `h-150` (for larger containers)
|
||||||
|
- **Viewport**: Use `h-svh` instead of `h-screen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Text Sizes
|
||||||
|
|
||||||
|
`text-2xs` to `text-9xl` - all fluid (clamp-based).
|
||||||
|
|
||||||
|
Key sizes:
|
||||||
|
- `text-base` - body text
|
||||||
|
- `text-6xl` - section headings
|
||||||
|
- `text-7xl`/`text-8xl` - hero headings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
Use theme-aware classes:
|
||||||
|
- `rounded-theme` - uses ThemeProvider setting
|
||||||
|
- `rounded-theme-capped` - max xl
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hero Page Padding
|
||||||
|
|
||||||
|
Special padding for hero sections that accounts for navbar:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<section className="py-hero-page-padding" /> // Standard
|
||||||
|
<section className="py-hero-page-padding-half" /> // Half
|
||||||
|
<section className="py-hero-page-padding-1_5" /> // 1.5x
|
||||||
|
<section className="py-hero-page-padding-double" /> // Double
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Variables
|
||||||
|
|
||||||
|
Defined in `src/app/styles/variables.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--background: #f5f4ef /* Page background */
|
||||||
|
--card: #dad6cd /* Card backgrounds */
|
||||||
|
--foreground: #2a2928 /* Text color */
|
||||||
|
--primary-cta: #2a2928 /* Primary button background */
|
||||||
|
--primary-cta-text: #f5f4ef /* Primary button text */
|
||||||
|
--secondary-cta: #ecebea /* Secondary button background */
|
||||||
|
--secondary-cta-text: #2a2928 /* Secondary button text */
|
||||||
|
--accent: #ffffff /* Accent highlights, glows */
|
||||||
|
--background-accent: #c6b180 /* Accent variant */
|
||||||
|
```
|
||||||
|
|
||||||
|
Use as Tailwind classes: `bg-background`, `text-foreground`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dynamic CSS Classes
|
||||||
|
|
||||||
|
These classes are styled based on ThemeProvider configuration:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="card rounded-theme p-6">Card content</div>
|
||||||
|
<button className="primary-button rounded-theme px-6 py-3">Primary</button>
|
||||||
|
<button className="secondary-button rounded-theme px-6 py-3">Secondary</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inverted Background Pattern
|
||||||
|
|
||||||
|
For sections that need dark backgrounds:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Required prop - forces explicit choice
|
||||||
|
useInvertedBackground: boolean
|
||||||
|
|
||||||
|
// Usage in component
|
||||||
|
<section className={cls("w-full", useInvertedBackground && "bg-foreground")}>
|
||||||
|
<p className={useInvertedBackground ? "text-background" : "text-foreground"}>
|
||||||
|
Content
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using CSS Variables Directly
|
||||||
|
|
||||||
|
When you need values not available as Tailwind classes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bottom-[calc(var(--spacing-4)+var(--spacing-4))]" />
|
||||||
|
<div className="left-[calc(var(--vw-1)*2)]" />
|
||||||
|
```
|
||||||
69
.claude/rules/transformation.md
Normal file
69
.claude/rules/transformation.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/components/**/*.tsx"
|
||||||
|
- "src/hooks/**/*.ts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# v2 to v4 Transformation
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| v2 | v4 |
|
||||||
|
|----|-----|
|
||||||
|
| GSAP + ScrollTrigger | `motion/react` with `whileInView` |
|
||||||
|
| Character-level text hooks | `TextAnimation` component (word-level) |
|
||||||
|
| `buttons: ButtonConfig[]` | `primaryButton` / `secondaryButton` objects |
|
||||||
|
| 10+ className props | Single `className` prop |
|
||||||
|
| CardStack/TextBox wrappers | Direct composition |
|
||||||
|
| Complex media props | Discriminated union: `{ imageSrc } \| { videoSrc }` |
|
||||||
|
|
||||||
|
## Animation Variants
|
||||||
|
|
||||||
|
| v2 | v4 |
|
||||||
|
|----|-----|
|
||||||
|
| `entrance-slide` | `slide-up` |
|
||||||
|
| `reveal-blur` | `fade-blur` |
|
||||||
|
| `background-highlight` | `fade` |
|
||||||
|
|
||||||
|
## Allowed Hooks
|
||||||
|
|
||||||
|
Only 2 custom hooks exist in v4:
|
||||||
|
- `useCarouselControls` - Embla carousel state
|
||||||
|
- `useButtonClick` - Navigation handling
|
||||||
|
|
||||||
|
All other animation hooks are replaced by `TextAnimation`, `Button animate`, or inline `motion`.
|
||||||
|
|
||||||
|
## Standard Motion Animation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type SectionProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
1. Replace GSAP with `motion/react`
|
||||||
|
2. Replace animation hooks with `TextAnimation` or `Button animate`
|
||||||
|
3. Use discriminated unions for media props
|
||||||
|
4. Use `primaryButton`/`secondaryButton` instead of buttons array
|
||||||
|
5. Remove all className props except single `className`
|
||||||
|
6. Replace CardStack with `GridOrCarousel`
|
||||||
|
7. Replace TextBox with direct `TextAnimation` + `Button` composition
|
||||||
|
8. Add `aria-label` to sections
|
||||||
|
9. Use `w-content-width` for containers
|
||||||
41
.claude/skills/add-animation/SKILL.md
Normal file
41
.claude/skills/add-animation/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: add-animation
|
||||||
|
description: Add animation to a component with proper setup and cleanup
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Animation
|
||||||
|
|
||||||
|
Add animation to: $ARGUMENTS
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Choose animation library**
|
||||||
|
- GSAP: Complex timelines, ScrollTrigger, text splitting
|
||||||
|
- Framer Motion: Simple component animations, gestures, layout
|
||||||
|
- CSS: Basic transitions, hover states
|
||||||
|
|
||||||
|
2. **For GSAP animations**
|
||||||
|
- Register plugins at file level
|
||||||
|
- Use `gsap.context()` for cleanup
|
||||||
|
- Return `() => ctx.revert()` from useEffect
|
||||||
|
- Use refs for target elements
|
||||||
|
|
||||||
|
3. **For Framer Motion**
|
||||||
|
- Wrap conditionally rendered elements in `AnimatePresence`
|
||||||
|
- Add `key` prop for AnimatePresence children
|
||||||
|
- Use `initial`, `animate`, `exit` props
|
||||||
|
|
||||||
|
4. **For scroll-triggered animations**
|
||||||
|
- Set appropriate `start` and `end` points
|
||||||
|
- Use `toggleActions` for replay behavior
|
||||||
|
- Consider mobile: disable or simplify
|
||||||
|
|
||||||
|
5. **Performance considerations**
|
||||||
|
- Use transform properties (x, y, scale, rotation)
|
||||||
|
- Add `force3D: true` for GPU acceleration
|
||||||
|
- Check if animation should be disabled on mobile
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Show the implemented animation code with proper cleanup.
|
||||||
47
.claude/skills/add-to-registry/SKILL.md
Normal file
47
.claude/skills/add-to-registry/SKILL.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: add-to-registry
|
||||||
|
description: Add a component to the registry system for AI tooling
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add to Registry
|
||||||
|
|
||||||
|
Add component to registry: $ARGUMENTS
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Identify component props**
|
||||||
|
- Read the component's TypeScript interface
|
||||||
|
- Note required vs optional props
|
||||||
|
- Note default values
|
||||||
|
|
||||||
|
2. **Update registry.json**
|
||||||
|
- Add full entry with constraints, propsSchema, usage example
|
||||||
|
- Include textRules for text props (min/max chars)
|
||||||
|
- Include do/dont guidelines
|
||||||
|
|
||||||
|
3. **Create component config**
|
||||||
|
- Create `registry/components/[Name].json`
|
||||||
|
- Match structure of existing component configs
|
||||||
|
|
||||||
|
4. **Create schema file**
|
||||||
|
- Create `registry/schemas/[Name].schema.json`
|
||||||
|
- Include name and propsSchema
|
||||||
|
- Use `?` suffix for optional props
|
||||||
|
- Include `(default: 'value')` for defaults
|
||||||
|
|
||||||
|
5. **Update index.json**
|
||||||
|
- Add entry with category, intent, bestFor, avoidWhen
|
||||||
|
- Include requires array and import path
|
||||||
|
|
||||||
|
6. **Update intents.json**
|
||||||
|
- Add component to relevant intent arrays
|
||||||
|
|
||||||
|
7. **Verify consistency**
|
||||||
|
- Props in schema match component interface exactly
|
||||||
|
- Optional props marked with `?`
|
||||||
|
- Default values documented
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report all updated registry files and confirm props match component.
|
||||||
61
.claude/skills/code-review/SKILL.md
Normal file
61
.claude/skills/code-review/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: code-review
|
||||||
|
description: Frontend code review checklist for React, TypeScript, and Tailwind
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Code Review
|
||||||
|
|
||||||
|
Review recently changed files against this checklist.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Identify Changed Files**
|
||||||
|
- Run `git diff --name-only` to find modified files
|
||||||
|
- Focus on `.tsx`, `.ts` files in `src/`
|
||||||
|
|
||||||
|
2. **Review Each File**
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- [ ] No `any` types (use proper types or `unknown`)
|
||||||
|
- [ ] Interfaces for component props
|
||||||
|
- [ ] No unused imports or variables
|
||||||
|
- [ ] Proper error handling (no silent catches)
|
||||||
|
|
||||||
|
### React Patterns
|
||||||
|
|
||||||
|
- [ ] `"use client"` only when needed (hooks, events, browser APIs)
|
||||||
|
- [ ] No unnecessary `React.memo`
|
||||||
|
- [ ] Stable callbacks with `useCallback` when passed as props
|
||||||
|
- [ ] Cleanup in `useEffect` (event listeners, subscriptions, GSAP)
|
||||||
|
- [ ] Keys on mapped elements (not index unless static list)
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
- [ ] Logic extracted to hooks (if complex)
|
||||||
|
- [ ] Props interface defined
|
||||||
|
- [ ] Default export at bottom
|
||||||
|
|
||||||
|
### Tailwind/Styling
|
||||||
|
|
||||||
|
- [ ] Using `cls()` for conditional classes
|
||||||
|
- [ ] Responsive classes (mobile-first)
|
||||||
|
- [ ] No hardcoded colors (use CSS variables)
|
||||||
|
- [ ] Proper z-index layering
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- [ ] Images use `next/image` with proper sizing
|
||||||
|
- [ ] Heavy components use dynamic import if below fold
|
||||||
|
- [ ] No unnecessary re-renders (check deps arrays)
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- [ ] Interactive elements are focusable
|
||||||
|
- [ ] Images have alt text
|
||||||
|
- [ ] Buttons have accessible names
|
||||||
|
- [ ] `aria-hidden` on decorative elements
|
||||||
|
|
||||||
|
3. **Report Findings**
|
||||||
|
- List issues grouped by severity: Critical, Warning, Suggestion
|
||||||
|
- Provide specific line numbers and fixes
|
||||||
47
.claude/skills/debug/SKILL.md
Normal file
47
.claude/skills/debug/SKILL.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: debug
|
||||||
|
description: Systematic debugging workflow to find and fix issues
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
|
||||||
|
Debug issue: $ARGUMENTS
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Understand the problem**
|
||||||
|
- What is the expected behavior?
|
||||||
|
- What is the actual behavior?
|
||||||
|
- When did it start happening?
|
||||||
|
- Is it reproducible?
|
||||||
|
|
||||||
|
2. **Gather information**
|
||||||
|
- Check browser console for errors
|
||||||
|
- Check terminal for server errors
|
||||||
|
- Look at network requests
|
||||||
|
- Check component props/state
|
||||||
|
|
||||||
|
3. **Isolate the cause**
|
||||||
|
- Find the smallest reproduction
|
||||||
|
- Identify which component/function is involved
|
||||||
|
- Check recent changes to that area
|
||||||
|
- Add console.logs or debugger statements
|
||||||
|
|
||||||
|
4. **Form hypothesis**
|
||||||
|
- Based on evidence, what could cause this?
|
||||||
|
- List possible causes in order of likelihood
|
||||||
|
|
||||||
|
5. **Test and fix**
|
||||||
|
- Test most likely cause first
|
||||||
|
- Make minimal change to fix
|
||||||
|
- Verify fix doesn't break other things
|
||||||
|
|
||||||
|
6. **Verify**
|
||||||
|
- Confirm original issue is resolved
|
||||||
|
- Test related functionality
|
||||||
|
- Remove debug statements
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report findings, root cause, and the fix applied.
|
||||||
57
.claude/skills/performance/SKILL.md
Normal file
57
.claude/skills/performance/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
name: performance
|
||||||
|
description: Performance optimization workflow for frontend applications
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
|
||||||
|
Optimize: $ARGUMENTS
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Identify the problem**
|
||||||
|
- What is slow? (initial load, interaction, render)
|
||||||
|
- Measure current performance
|
||||||
|
- Set a target improvement
|
||||||
|
|
||||||
|
2. **Analyze causes**
|
||||||
|
- Large bundle size?
|
||||||
|
- Unnecessary re-renders?
|
||||||
|
- Expensive calculations?
|
||||||
|
- Network waterfalls?
|
||||||
|
- Layout thrashing?
|
||||||
|
|
||||||
|
3. **React optimizations**
|
||||||
|
- Add `memo()` for expensive list items
|
||||||
|
- Use `useMemo` for costly computations
|
||||||
|
- Use `useCallback` for stable function refs
|
||||||
|
- Split components to isolate re-renders
|
||||||
|
- Lazy load with `dynamic()` or `React.lazy()`
|
||||||
|
|
||||||
|
4. **Bundle optimizations**
|
||||||
|
- Code split large dependencies
|
||||||
|
- Dynamic imports for heavy features
|
||||||
|
- Tree shake unused code
|
||||||
|
- Analyze with bundle analyzer
|
||||||
|
|
||||||
|
5. **Render optimizations**
|
||||||
|
- Virtualize long lists
|
||||||
|
- Debounce/throttle frequent updates
|
||||||
|
- Use CSS transforms over layout properties
|
||||||
|
- Avoid layout shifts
|
||||||
|
|
||||||
|
6. **Network optimizations**
|
||||||
|
- Preload critical resources
|
||||||
|
- Cache API responses
|
||||||
|
- Optimize images (WebP, lazy load)
|
||||||
|
- Reduce request waterfalls
|
||||||
|
|
||||||
|
7. **Verify improvement**
|
||||||
|
- Measure after changes
|
||||||
|
- Compare to baseline
|
||||||
|
- Test on slow devices/networks
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report optimizations applied and measured improvements.
|
||||||
55
.claude/skills/refactor/SKILL.md
Normal file
55
.claude/skills/refactor/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: refactor
|
||||||
|
description: Systematic refactoring workflow for safe code improvements
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor
|
||||||
|
|
||||||
|
Refactor: $ARGUMENTS
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Understand current state**
|
||||||
|
- Read the code to refactor
|
||||||
|
- Identify what it does and why
|
||||||
|
- Note all usages/dependencies
|
||||||
|
- Check for existing tests
|
||||||
|
|
||||||
|
2. **Define the goal**
|
||||||
|
- What problem are we solving?
|
||||||
|
- Readability? Performance? Maintainability?
|
||||||
|
- What should NOT change? (behavior, API, **UI**)
|
||||||
|
|
||||||
|
3. **Plan changes**
|
||||||
|
- Break into small, safe steps
|
||||||
|
- Each step should leave code working
|
||||||
|
- Identify risk points
|
||||||
|
|
||||||
|
4. **Execute incrementally**
|
||||||
|
- One change at a time
|
||||||
|
- Verify after each step
|
||||||
|
- Keep commits atomic
|
||||||
|
|
||||||
|
5. **Common refactors**
|
||||||
|
- Extract function/component
|
||||||
|
- Rename for clarity
|
||||||
|
- Simplify conditionals
|
||||||
|
- Remove duplication
|
||||||
|
- Split large files
|
||||||
|
- Move code to better location
|
||||||
|
|
||||||
|
6. **Verify**
|
||||||
|
- Run existing tests
|
||||||
|
- Test affected functionality
|
||||||
|
- Check for regressions
|
||||||
|
- Ensure behavior unchanged
|
||||||
|
|
||||||
|
7. **UI Preservation (Critical)**
|
||||||
|
- Compare CSS classes line by line before consolidating
|
||||||
|
- Duplicate code may have subtle differences (sizes, colors)
|
||||||
|
- Never assume - verify before merging branches
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report changes made, files affected, and verification steps taken.
|
||||||
59
.claude/skills/scaffold-component/SKILL.md
Normal file
59
.claude/skills/scaffold-component/SKILL.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: scaffold-component
|
||||||
|
description: Generate a new React component following project conventions
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scaffold Component
|
||||||
|
|
||||||
|
Generate a new component at the specified path with proper structure.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
`/scaffold-component ComponentName path/to/component`
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Parse Arguments**
|
||||||
|
- First argument: Component name (PascalCase)
|
||||||
|
- Second argument: Path relative to `src/components/`
|
||||||
|
|
||||||
|
2. **Create Component File**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface $NAMEProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $NAME = ({ className }: $NAMEProps) => {
|
||||||
|
return <div className={cls("", className)}>{/* TODO: Implement */}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default $NAME;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Determine if Hook is Needed**
|
||||||
|
- Ask user if component needs state/effects
|
||||||
|
- If yes, create hook file at `src/hooks/{domain}/use{Name}.ts`
|
||||||
|
|
||||||
|
4. **Determine if Constants are Needed**
|
||||||
|
- Ask user if component has static text
|
||||||
|
- If yes, create constants file at `src/constants/{domain}/{name}.ts`
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
- Component: `ComponentName.tsx` (PascalCase)
|
||||||
|
- Hook: `useComponentName.ts` (camelCase with use prefix)
|
||||||
|
- Constants: `componentName.ts` (camelCase)
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report created files and remind user to:
|
||||||
|
|
||||||
|
1. Add necessary imports
|
||||||
|
2. Implement the component logic
|
||||||
|
3. Add to parent component/page
|
||||||
4
.env
Normal file
4
.env
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
VITE_API_URL=https://dev.api.webild.io
|
||||||
|
VITE_PROJECT_ID=5af4f2f1-9b23-4235-ab1d-ef7ccf77fabf
|
||||||
|
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# API Configuration
|
||||||
|
VITE_API_URL=
|
||||||
|
VITE_PROJECT_ID=
|
||||||
46
.gitea/workflows/build.yml
Normal file
46
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Code Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: git clone --depth 1 --branch ${{ gitea.ref_name }} ${{ gitea.server_url }}/${{ gitea.repository }}.git . || exit 1
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
if [ -f "vite.config.ts" ]; then
|
||||||
|
if [ -d "/var/node_modules_cache_v4/node_modules" ]; then
|
||||||
|
ln -s /var/node_modules_cache_v4/node_modules ./node_modules
|
||||||
|
elif [ -f "pnpm-lock.yaml" ]; then
|
||||||
|
npm install -g pnpm --silent
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
else
|
||||||
|
npm ci --prefer-offline --no-audit
|
||||||
|
fi
|
||||||
|
elif [ -d "/var/node_modules_cache/node_modules" ]; then
|
||||||
|
ln -s /var/node_modules_cache/node_modules ./node_modules
|
||||||
|
else
|
||||||
|
npm ci --prefer-offline --no-audit
|
||||||
|
fi
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
- name: TypeScript check
|
||||||
|
run: npm run typecheck 2>&1 | tee build.log
|
||||||
|
timeout-minutes: 3
|
||||||
|
|
||||||
|
- name: ESLint check
|
||||||
|
run: npm run lint 2>&1 | tee -a build.log
|
||||||
|
timeout-minutes: 3
|
||||||
|
|
||||||
|
- name: Upload build log on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: build-log
|
||||||
|
path: build.log
|
||||||
|
retention-days: 1
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
218
STRUCTURE.md
Normal file
218
STRUCTURE.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# AI-Friendly Component Library
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Current architecture is optimized for human developers (DRY, reusable, flexible) but this makes AI editing fail. AI can't trace through ThemeProvider indirection, 40+ prop interfaces, nested component layers, and custom Tailwind scales. It hallucinates because it can't see the actual output.
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Shift from human-optimized to AI-optimized code:
|
||||||
|
- **Explicit over implicit** - All styles visible in file, no ThemeProvider magic
|
||||||
|
- **Flat over nested** - Direct components, no wrapper layers to trace through
|
||||||
|
- **Concrete over abstract** - Actual Tailwind classes, not prop names that map to hidden styles
|
||||||
|
- **Standard over custom** - Default Tailwind values AI already knows
|
||||||
|
- **Simple over flexible** - One way to do things, not 11 variants
|
||||||
|
|
||||||
|
Components become templates that AI reads, understands, and edits directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Backend-Driven Sections
|
||||||
|
|
||||||
|
Generated website repository only contains what is used, plus general UI components. Section components are stored on the backend and injected when needed, not bundled in the boilerplate.
|
||||||
|
|
||||||
|
**Alternative:** If backend approach takes too long, keep all sections in the boilerplate but with the new simplified structure defined below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Props & Structure
|
||||||
|
|
||||||
|
Remove all styling/className props. AI edits classes directly in the component file.
|
||||||
|
|
||||||
|
**Option A - Minimal props:** Keep text, links, assets, arrays, icons as props. Backend passes content, AI edits props in page file.
|
||||||
|
|
||||||
|
**Option B - No props:** All content hardcoded in section file. Backend injects entire file with content baked in. AI edits section file directly. Maximum explicitness.
|
||||||
|
|
||||||
|
**Flat structure:** Sections use direct components (TextAnimation, Button, MediaContent) not wrapper components like TextBox that nest other components inside.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Framer Motion for Element Animations
|
||||||
|
|
||||||
|
Replace GSAP (ScrollTrigger, `gsap.context()`, `ctx.revert()`) with Framer Motion `motion` divs for cards, buttons, tags, and other elements. Use `whileInView` for scroll-triggered animations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Simplified Tailwind & Single CSS File
|
||||||
|
|
||||||
|
Remove all custom Tailwind values (spacing, widths, gaps). Use default Tailwind classes.
|
||||||
|
|
||||||
|
**Keep:**
|
||||||
|
- Content width approach (for page width and carousels)
|
||||||
|
- Fluid font sizes (AI picks bad font sizes otherwise)
|
||||||
|
|
||||||
|
**One globals.css file with:**
|
||||||
|
- Color variables in `:root`
|
||||||
|
- `--radius` variable, applied globally via `@layer base`
|
||||||
|
- Card styles
|
||||||
|
- Button styles
|
||||||
|
- Utility classes (masks, animations) - consolidated, fewer variants
|
||||||
|
- Base styling (body defaults, scrollbar, heading font-family)
|
||||||
|
|
||||||
|
AI picks from a reference list of available styles and writes them directly to globals.css.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Remove ThemeProvider
|
||||||
|
|
||||||
|
Delete the entire ThemeProvider system. No dynamic style injection.
|
||||||
|
|
||||||
|
**Card/button styles:** AI chooses on backend, adds one card style, one primary button style, one secondary button style to globals.css.
|
||||||
|
|
||||||
|
**Text animations:** Remove TextBox component. Use simple `TextAnimation` component in section files with `text` and `animationType` props.
|
||||||
|
|
||||||
|
**Button:** Single `components/ui/Button.tsx` with `text`, `href`, and `className` (e.g. `primary-button` defined in globals.css).
|
||||||
|
|
||||||
|
**Content width:** Fixed value in globals.css.
|
||||||
|
|
||||||
|
**Text sizing:** Fixed values in globals.css. Future: AI picks from 3 presets on backend and adds to globals.css.
|
||||||
|
|
||||||
|
**Background components:** Remove all (aurora, grid, floatingGradient, etc.).
|
||||||
|
|
||||||
|
**Fonts:** Keep current logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. New Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Pages
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # Atomic UI components
|
||||||
|
│ └── [sections] # Section components flat, not nested (injected from backend)
|
||||||
|
├── hooks/ # Common hooks
|
||||||
|
├── lib/ # Utilities
|
||||||
|
└── styles/
|
||||||
|
└── globals.css # Single CSS file
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove: `providers/themeProvider/`, multiple CSS files, `button/` variants folder, `text/` folder, `background/` folder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Atomic UI Components
|
||||||
|
|
||||||
|
Add simple, single-purpose components to `components/ui/`:
|
||||||
|
|
||||||
|
Button, TextAnimation, MediaContent, Toggle, Dropdown, Accordion, Avatar, Badge, Breadcrumb, Calendar, Chart, Checkbox, Form, Input, Label, Tooltip
|
||||||
|
|
||||||
|
Expand as needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Simplify Complex Components
|
||||||
|
|
||||||
|
**Navbar:** Separate navbar components instead of one with variants. AI chooses which one. Reduce to 4 navbars for now.
|
||||||
|
|
||||||
|
**CardStack → GridLayout:** Replace complex CardStack (mode, gridVariant, carouselThreshold, ~40 props) with simple wrapper. Children as items. Single prop: items per row (3 or 4). Under = grid, over = carousel. Carousel mode keeps controls (prev/next, progress bar). No title/description/animation props - sections handle all that directly. Remove timelines, grid configs, auto-carousel variants for now, refactor later.
|
||||||
|
|
||||||
|
**TextBox:** Remove completely. Use TextAnimation and Button directly in sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Consistent File Patterns
|
||||||
|
|
||||||
|
Every section file follows the EXACT same structure. AI sees one section, knows how all work.
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```
|
||||||
|
1. IMPORTS (same order: react, framer-motion, ui components, icons)
|
||||||
|
2. CONTENT (variables at top if Option B)
|
||||||
|
3. COMPONENT (predictable JSX order)
|
||||||
|
4. EXPORT (always at bottom)
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSX order:** tag → title → description → buttons → media (always same sequence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Short Files, No Sub-components
|
||||||
|
|
||||||
|
**Line limits:**
|
||||||
|
- Sections: under 100 lines
|
||||||
|
- UI components: under 50 lines
|
||||||
|
|
||||||
|
**Key rule:** Don't split into sub-components to achieve this. Sub-components add nesting/indirection. Instead, simplify the section itself.
|
||||||
|
|
||||||
|
If a section is too long, it's too complex. Simplify the design, don't extract parts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Content at Top
|
||||||
|
|
||||||
|
If using Option B (no props), put all editable content as variables at the very top of the file.
|
||||||
|
|
||||||
|
AI immediately knows: "edit content? check the top."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Remove Complex Components
|
||||||
|
|
||||||
|
Remove overly complex components for now. Add back simplified versions later if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. CSS Class Organization
|
||||||
|
|
||||||
|
**Consistent class order:** layout → spacing → sizing → typography → colors → effects
|
||||||
|
|
||||||
|
**Keep className short.** Too many classes = section too complex. Use global CSS (`.card`, `.btn-primary`) for repeated patterns.
|
||||||
|
|
||||||
|
**No dynamic classes in sections.** Avoid `cls(condition && "class")`. Keep it explicit.
|
||||||
|
|
||||||
|
**One line if short, split by category if long.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Foundation (Delete)
|
||||||
|
- Delete `providers/themeProvider/` (13 files)
|
||||||
|
- Delete `components/background/` (27 files)
|
||||||
|
- Delete `components/Textbox.tsx`
|
||||||
|
- Delete `components/text/` (GSAP TextAnimation)
|
||||||
|
- Consolidate all CSS → single `styles/globals.css`
|
||||||
|
|
||||||
|
### Phase 2: Core Simplification
|
||||||
|
- Button → single `ui/Button.tsx` (50 lines max)
|
||||||
|
- Navbar → 4 separate components, no animation hooks
|
||||||
|
- TextAnimation → new Framer Motion version
|
||||||
|
- CardStack → simple GridLayout wrapper
|
||||||
|
|
||||||
|
### Phase 3: Section Refactoring
|
||||||
|
- Remove all className/containerClassName props
|
||||||
|
- Remove useTheme() calls
|
||||||
|
- Each section under 100 lines
|
||||||
|
- Content variables at top (if Option B)
|
||||||
|
- Flat structure, no nested wrappers
|
||||||
|
|
||||||
|
### Phase 4: Organize
|
||||||
|
- Create `components/ui/` with atomic components
|
||||||
|
- Move hooks to `hooks/` folder
|
||||||
|
- Update all imports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current → Target
|
||||||
|
|
||||||
|
| Metric | Current | Target |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| Total files | ~270 | ~100 |
|
||||||
|
| Lines of code | ~25,000 | ~10,000 |
|
||||||
|
| Props per section | 20-40 | 5-10 |
|
||||||
|
| CSS files | 37 | 1 |
|
||||||
|
| Button variants | 11 | 1 |
|
||||||
|
| Navbar variants | 5 | 4 |
|
||||||
|
|
||||||
|
---
|
||||||
58
THEME_PROVIDER_OPTIONS.txt
Normal file
58
THEME_PROVIDER_OPTIONS.txt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
================================================================================
|
||||||
|
THEME OPTIONS (v4)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
STYLE PROPS
|
||||||
|
-----------
|
||||||
|
|
||||||
|
1. borderRadius
|
||||||
|
• "rounded"
|
||||||
|
• "soft"
|
||||||
|
• "pill"
|
||||||
|
|
||||||
|
2. contentWidth
|
||||||
|
• "small"
|
||||||
|
• "compact"
|
||||||
|
• "medium"
|
||||||
|
• "mediumLarge"
|
||||||
|
|
||||||
|
3. cardStyle
|
||||||
|
• "solid"
|
||||||
|
• "outline"
|
||||||
|
• "gradient-mesh"
|
||||||
|
• "gradient-radial"
|
||||||
|
• "inset"
|
||||||
|
• "glass-elevated"
|
||||||
|
• "glass-depth"
|
||||||
|
• "gradient-bordered"
|
||||||
|
• "layered-gradient"
|
||||||
|
• "soft-shadow"
|
||||||
|
• "subtle-shadow"
|
||||||
|
• "elevated-border"
|
||||||
|
• "inner-glow"
|
||||||
|
• "spotlight"
|
||||||
|
|
||||||
|
4. primaryButtonStyle
|
||||||
|
• "gradient"
|
||||||
|
• "shadow"
|
||||||
|
• "flat"
|
||||||
|
• "radial-glow"
|
||||||
|
• "diagonal-gradient"
|
||||||
|
• "double-inset"
|
||||||
|
• "primary-glow"
|
||||||
|
• "inset-glow"
|
||||||
|
• "soft-glow"
|
||||||
|
• "glass-shimmer"
|
||||||
|
• "neon-outline"
|
||||||
|
• "lifted"
|
||||||
|
• "depth-layers"
|
||||||
|
• "accent-edge"
|
||||||
|
• "metallic"
|
||||||
|
|
||||||
|
5. secondaryButtonStyle
|
||||||
|
• "glass"
|
||||||
|
• "solid"
|
||||||
|
• "layered"
|
||||||
|
• "radial-glow"
|
||||||
|
|
||||||
|
================================================================================
|
||||||
677
colorThemes.json
Normal file
677
colorThemes.json
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
{
|
||||||
|
"lightTheme": {
|
||||||
|
"minimalDarkBlue": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#000612e6",
|
||||||
|
"--primary-cta": "#15479c",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#c4c4c4",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#000612e6"
|
||||||
|
},
|
||||||
|
"minimalDarkGreen": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#000f06e6",
|
||||||
|
"--primary-cta": "#0a7039",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#c4c4c4",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#000f06e6"
|
||||||
|
},
|
||||||
|
"minimalLightRed": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#120006e6",
|
||||||
|
"--primary-cta": "#e63946",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#c4c4c4",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#120006e6"
|
||||||
|
},
|
||||||
|
"minimalBrightBlue": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#000612e6",
|
||||||
|
"--primary-cta": "#106EFB",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#106EFB",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#000612e6"
|
||||||
|
},
|
||||||
|
"minimalBrightOrange": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#120a00e6",
|
||||||
|
"--primary-cta": "#E34400",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#E34400",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#120a00e6"
|
||||||
|
},
|
||||||
|
"minimalGoldenOrange": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#120a00e6",
|
||||||
|
"--primary-cta": "#FF7B05",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#FF7B05",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#120a00e6"
|
||||||
|
},
|
||||||
|
"minimalLightOrange": {
|
||||||
|
"--background": "#ffffff",
|
||||||
|
"--card": "#f9f9f9",
|
||||||
|
"--foreground": "#120a00e6",
|
||||||
|
"--primary-cta": "#ff8c42",
|
||||||
|
"--secondary-cta": "#f9f9f9",
|
||||||
|
"--accent": "#e2e2e2",
|
||||||
|
"--background-accent": "#c4c4c4",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#120a00e6"
|
||||||
|
},
|
||||||
|
"darkBlue": {
|
||||||
|
"--background": "#f5faff",
|
||||||
|
"--card": "#f1f8ff",
|
||||||
|
"--foreground": "#001122",
|
||||||
|
"--primary-cta": "#15479c",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#a8cce8",
|
||||||
|
"--background-accent": "#7ba3cf",
|
||||||
|
"--primary-cta-text": "#f5faff",
|
||||||
|
"--secondary-cta-text": "#001122"
|
||||||
|
},
|
||||||
|
"darkGreen": {
|
||||||
|
"--background": "#fafffb",
|
||||||
|
"--card": "#f7fffa",
|
||||||
|
"--foreground": "#001a0a",
|
||||||
|
"--primary-cta": "#0a7039",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#a8d9be",
|
||||||
|
"--background-accent": "#6bbf8e",
|
||||||
|
"--primary-cta-text": "#fafffb",
|
||||||
|
"--secondary-cta-text": "#001a0a"
|
||||||
|
},
|
||||||
|
"lightRed": {
|
||||||
|
"--background": "#fffafa",
|
||||||
|
"--card": "#fff7f7",
|
||||||
|
"--foreground": "#1a0000",
|
||||||
|
"--primary-cta": "#e63946",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#f5c4c7",
|
||||||
|
"--background-accent": "#f09199",
|
||||||
|
"--primary-cta-text": "#fffafa",
|
||||||
|
"--secondary-cta-text": "#1a0000"
|
||||||
|
},
|
||||||
|
"lightPurple": {
|
||||||
|
"--background": "#fbfaff",
|
||||||
|
"--card": "#f7f5ff",
|
||||||
|
"--foreground": "#0f0022",
|
||||||
|
"--primary-cta": "#8b5cf6",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#d8cef5",
|
||||||
|
"--background-accent": "#c4a8f9",
|
||||||
|
"--primary-cta-text": "#fbfaff",
|
||||||
|
"--secondary-cta-text": "#0f0022"
|
||||||
|
},
|
||||||
|
"warmCream": {
|
||||||
|
"--background": "#f6f0e9",
|
||||||
|
"--card": "#efe7dd",
|
||||||
|
"--foreground": "#2b180a",
|
||||||
|
"--primary-cta": "#2b180a",
|
||||||
|
"--secondary-cta": "#efe7dd",
|
||||||
|
"--accent": "#94877c",
|
||||||
|
"--background-accent": "#afa094",
|
||||||
|
"--primary-cta-text": "#f6f0e9",
|
||||||
|
"--secondary-cta-text": "#2b180a"
|
||||||
|
},
|
||||||
|
"grayBlueAccent": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#1c1c1c",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#15479c",
|
||||||
|
"--background-accent": "#a8cce8",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"grayGreenAccent": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#1c1c1c",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#159c49",
|
||||||
|
"--background-accent": "#a8e8ba",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"grayRedAccent": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#1c1c1c",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#e63946",
|
||||||
|
"--background-accent": "#e8bea8",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"grayPurpleAccent": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#1c1c1c",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#6139e6",
|
||||||
|
"--background-accent": "#b3a8e8",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"warmBeige": {
|
||||||
|
"--background": "#efebe5",
|
||||||
|
"--card": "#f7f2ea",
|
||||||
|
"--foreground": "#000000",
|
||||||
|
"--primary-cta": "#000000",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#ffffff",
|
||||||
|
"--background-accent": "#e1b875",
|
||||||
|
"--primary-cta-text": "#efebe5",
|
||||||
|
"--secondary-cta-text": "#000000"
|
||||||
|
},
|
||||||
|
"grayTealGreen": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#1f514c",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#159c49",
|
||||||
|
"--background-accent": "#a8e8ba",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"grayNavyBlue": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#1f3251",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#15479c",
|
||||||
|
"--background-accent": "#a8cce8",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"grayBurgundyRed": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#511f1f",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#e63946",
|
||||||
|
"--background-accent": "#e8bea8",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"grayIndigoPurple": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#341f51",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#6139e6",
|
||||||
|
"--background-accent": "#b3a8e8",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
},
|
||||||
|
"warmgrayPink": {
|
||||||
|
"--background": "#f7f6f7",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1b0c25",
|
||||||
|
"--primary-cta": "#1b0c25",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#ff93e4",
|
||||||
|
"--background-accent": "#e8a8c3",
|
||||||
|
"--primary-cta-text": "#f7f6f7",
|
||||||
|
"--secondary-cta-text": "#1b0c25"
|
||||||
|
},
|
||||||
|
"warmgrayOrange": {
|
||||||
|
"--background": "#f7f6f7",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#25190c",
|
||||||
|
"--primary-cta": "#ff6207",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#ffce93",
|
||||||
|
"--background-accent": "#e8cfa8",
|
||||||
|
"--primary-cta-text": "#f7f6f7",
|
||||||
|
"--secondary-cta-text": "#25190c"
|
||||||
|
},
|
||||||
|
"warmgrayBlue": {
|
||||||
|
"--background": "#f7f6f7",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#0c1325",
|
||||||
|
"--primary-cta": "#0798ff",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#93c7ff",
|
||||||
|
"--background-accent": "#a8cde8",
|
||||||
|
"--primary-cta-text": "#f7f6f7",
|
||||||
|
"--secondary-cta-text": "#0c1325"
|
||||||
|
},
|
||||||
|
"warmgrayIndigo": {
|
||||||
|
"--background": "#f7f6f7",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#0c1325",
|
||||||
|
"--primary-cta": "#0b07ff",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#93b7ff",
|
||||||
|
"--background-accent": "#a8bae8",
|
||||||
|
"--primary-cta-text": "#f7f6f7",
|
||||||
|
"--secondary-cta-text": "#0c1325"
|
||||||
|
},
|
||||||
|
"lavenderPeach": {
|
||||||
|
"--background": "#e3deea",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#27231f",
|
||||||
|
"--primary-cta": "#27231f",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#c68a62",
|
||||||
|
"--background-accent": "#c68a62",
|
||||||
|
"--primary-cta-text": "#e3deea",
|
||||||
|
"--secondary-cta-text": "#27231f"
|
||||||
|
},
|
||||||
|
"lavenderBlue": {
|
||||||
|
"--background": "#e3deea",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1f2027",
|
||||||
|
"--primary-cta": "#1f2027",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#627dc6",
|
||||||
|
"--background-accent": "#627dc6",
|
||||||
|
"--primary-cta-text": "#e3deea",
|
||||||
|
"--secondary-cta-text": "#1f2027"
|
||||||
|
},
|
||||||
|
"warmStone": {
|
||||||
|
"--background": "#f5f4ef",
|
||||||
|
"--card": "#dad6cd",
|
||||||
|
"--foreground": "#2a2928",
|
||||||
|
"--primary-cta": "#2a2928",
|
||||||
|
"--secondary-cta": "#ecebea",
|
||||||
|
"--accent": "#ffffff",
|
||||||
|
"--background-accent": "#c6b180",
|
||||||
|
"--primary-cta-text": "#f5f4ef",
|
||||||
|
"--secondary-cta-text": "#2a2928"
|
||||||
|
},
|
||||||
|
"warmStoneGray": {
|
||||||
|
"--background": "#f5f4f0",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1a1a1a",
|
||||||
|
"--primary-cta": "#2c2c2c",
|
||||||
|
"--secondary-cta": "#f5f4f0",
|
||||||
|
"--accent": "#8a8a8a",
|
||||||
|
"--background-accent": "#e8e6e1",
|
||||||
|
"--primary-cta-text": "#f5f4f0",
|
||||||
|
"--secondary-cta-text": "#1a1a1a"
|
||||||
|
},
|
||||||
|
"warmGreen": {
|
||||||
|
"--background": "#fffefe",
|
||||||
|
"--card": "#f6f7f4",
|
||||||
|
"--foreground": "#080908",
|
||||||
|
"--primary-cta": "#0e3a29",
|
||||||
|
"--secondary-cta": "#e7eecd",
|
||||||
|
"--accent": "#35c18b",
|
||||||
|
"--background-accent": "#ecebe4",
|
||||||
|
"--primary-cta-text": "#fffefe",
|
||||||
|
"--secondary-cta-text": "#080908"
|
||||||
|
},
|
||||||
|
"warmSand": {
|
||||||
|
"--background": "#fcf6ec",
|
||||||
|
"--card": "#f3ede2",
|
||||||
|
"--foreground": "#2e2521",
|
||||||
|
"--primary-cta": "#2e2521",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#b2a28b",
|
||||||
|
"--background-accent": "#b2a28b",
|
||||||
|
"--primary-cta-text": "#fcf6ec",
|
||||||
|
"--secondary-cta-text": "#2e2521"
|
||||||
|
},
|
||||||
|
"warmgrayRed": {
|
||||||
|
"--background": "#f7f6f7",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#250c0d",
|
||||||
|
"--primary-cta": "#b82b40",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#b90941",
|
||||||
|
"--background-accent": "#e8a8b6",
|
||||||
|
"--primary-cta-text": "#f7f6f7",
|
||||||
|
"--secondary-cta-text": "#250c0d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"darkTheme": {
|
||||||
|
"minimal": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#ffffffe6",
|
||||||
|
"--primary-cta": "#e6e6e6",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#ffffffe6"
|
||||||
|
},
|
||||||
|
"minimalLightBlue": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#f0f8ffe6",
|
||||||
|
"--primary-cta": "#cee7ff",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#f0f8ffe6"
|
||||||
|
},
|
||||||
|
"minimalLightGreen": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#f5fffae6",
|
||||||
|
"--primary-cta": "#80da9b",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#f5fffae6"
|
||||||
|
},
|
||||||
|
"minimalLightRed": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#fff5f5e6",
|
||||||
|
"--primary-cta": "#ff7a7a",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#fff5f5e6"
|
||||||
|
},
|
||||||
|
"minimalLightPurple": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#f8f5ffe6",
|
||||||
|
"--primary-cta": "#c89bff",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#f8f5ffe6"
|
||||||
|
},
|
||||||
|
"lightBlueWhite": {
|
||||||
|
"--background": "#010912",
|
||||||
|
"--card": "#152840",
|
||||||
|
"--foreground": "#e6f0ff",
|
||||||
|
"--primary-cta": "#cee7ff",
|
||||||
|
"--secondary-cta": "#0e1a29",
|
||||||
|
"--accent": "#3f5c79",
|
||||||
|
"--background-accent": "#004a93",
|
||||||
|
"--primary-cta-text": "#010912",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"lime": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#f5f5f5",
|
||||||
|
"--primary-cta": "#dfff1c",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#8b9a1b",
|
||||||
|
"--background-accent": "#5d6b00",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"gold": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#f5f5f5",
|
||||||
|
"--primary-cta": "#ffdf7d",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#b8860b",
|
||||||
|
"--background-accent": "#8b6914",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"crimson": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#f5f5f5",
|
||||||
|
"--primary-cta": "#ff0000",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#991b1b",
|
||||||
|
"--background-accent": "#7f1d1d",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"midnightIce": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#0c0c0c",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#cee7ff",
|
||||||
|
"--secondary-cta": "#000000",
|
||||||
|
"--accent": "#535353",
|
||||||
|
"--background-accent": "#CEE7FF",
|
||||||
|
"--primary-cta-text": "#000000",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"midnightBlue": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#0c0c0c",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#106EFB",
|
||||||
|
"--secondary-cta": "#000000",
|
||||||
|
"--accent": "#535353",
|
||||||
|
"--background-accent": "#106EFB",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"blueOrangeAccent": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#1f7cff",
|
||||||
|
"--secondary-cta": "#010101",
|
||||||
|
"--accent": "#1f7cff",
|
||||||
|
"--background-accent": "#f96b2f",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"orangeBlueAccent": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#e34400",
|
||||||
|
"--secondary-cta": "#010101",
|
||||||
|
"--accent": "#ff7b05",
|
||||||
|
"--background-accent": "#106efb",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"minimalBrightOrange": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#e34400",
|
||||||
|
"--secondary-cta": "#010101",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#e34400",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#ffffff"
|
||||||
|
},
|
||||||
|
"minimalLightOrange": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#fffaf5e6",
|
||||||
|
"--primary-cta": "#ffaa70",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#fffaf5e6"
|
||||||
|
},
|
||||||
|
"minimalLightYellow": {
|
||||||
|
"--background": "#0a0a0a",
|
||||||
|
"--card": "#1a1a1a",
|
||||||
|
"--foreground": "#fffffae6",
|
||||||
|
"--primary-cta": "#fde047",
|
||||||
|
"--secondary-cta": "#1a1a1a",
|
||||||
|
"--accent": "#737373",
|
||||||
|
"--background-accent": "#737373",
|
||||||
|
"--primary-cta-text": "#0a0a0a",
|
||||||
|
"--secondary-cta-text": "#fffffae6"
|
||||||
|
},
|
||||||
|
"lightBlue": {
|
||||||
|
"--background": "#010912",
|
||||||
|
"--card": "#152840",
|
||||||
|
"--foreground": "#e6f0ff",
|
||||||
|
"--primary-cta": "#cee7ff",
|
||||||
|
"--secondary-cta": "#0e1a29",
|
||||||
|
"--accent": "#3f5c79",
|
||||||
|
"--background-accent": "#004a93",
|
||||||
|
"--primary-cta-text": "#010912",
|
||||||
|
"--secondary-cta-text": "#e6f0ff"
|
||||||
|
},
|
||||||
|
"lightGreen": {
|
||||||
|
"--background": "#000802",
|
||||||
|
"--card": "#0b1a0b",
|
||||||
|
"--foreground": "#e6ffe6",
|
||||||
|
"--primary-cta": "#80da9b",
|
||||||
|
"--secondary-cta": "#07170b",
|
||||||
|
"--accent": "#38714a",
|
||||||
|
"--background-accent": "#2c6541",
|
||||||
|
"--primary-cta-text": "#000802",
|
||||||
|
"--secondary-cta-text": "#e6ffe6"
|
||||||
|
},
|
||||||
|
"lightRed": {
|
||||||
|
"--background": "#080000",
|
||||||
|
"--card": "#1e0d0d",
|
||||||
|
"--foreground": "#ffe6e6",
|
||||||
|
"--primary-cta": "#ff7a7a",
|
||||||
|
"--secondary-cta": "#1e0909",
|
||||||
|
"--accent": "#7b4242",
|
||||||
|
"--background-accent": "#65292c",
|
||||||
|
"--primary-cta-text": "#080000",
|
||||||
|
"--secondary-cta-text": "#ffe6e6"
|
||||||
|
},
|
||||||
|
"darkRed": {
|
||||||
|
"--background": "#060000",
|
||||||
|
"--card": "#1d0d0d",
|
||||||
|
"--foreground": "#ffe6e6",
|
||||||
|
"--primary-cta": "#ff3d4a",
|
||||||
|
"--secondary-cta": "#1f0a0a",
|
||||||
|
"--accent": "#7b2d2d",
|
||||||
|
"--background-accent": "#b8111f",
|
||||||
|
"--primary-cta-text": "#ffffff",
|
||||||
|
"--secondary-cta-text": "#ffe6e6"
|
||||||
|
},
|
||||||
|
"lightPurple": {
|
||||||
|
"--background": "#050012",
|
||||||
|
"--card": "#040121",
|
||||||
|
"--foreground": "#f0e6ff",
|
||||||
|
"--primary-cta": "#c89bff",
|
||||||
|
"--secondary-cta": "#1d123b",
|
||||||
|
"--accent": "#684f7b",
|
||||||
|
"--background-accent": "#65417c",
|
||||||
|
"--primary-cta-text": "#050012",
|
||||||
|
"--secondary-cta-text": "#f0e6ff"
|
||||||
|
},
|
||||||
|
"lightOrange": {
|
||||||
|
"--background": "#080200",
|
||||||
|
"--card": "#1a0d0b",
|
||||||
|
"--foreground": "#ffe6d5",
|
||||||
|
"--primary-cta": "#ffaa70",
|
||||||
|
"--secondary-cta": "#170b07",
|
||||||
|
"--accent": "#7b5e4a",
|
||||||
|
"--background-accent": "#b8541e",
|
||||||
|
"--primary-cta-text": "#080200",
|
||||||
|
"--secondary-cta-text": "#ffe6d5"
|
||||||
|
},
|
||||||
|
"deepBlue": {
|
||||||
|
"--background": "#020617",
|
||||||
|
"--card": "#0f172a",
|
||||||
|
"--foreground": "#e2e8f0",
|
||||||
|
"--primary-cta": "#c4d8f9",
|
||||||
|
"--secondary-cta": "#041633",
|
||||||
|
"--accent": "#2d30f3",
|
||||||
|
"--background-accent": "#1d4ed8",
|
||||||
|
"--primary-cta-text": "#020617",
|
||||||
|
"--secondary-cta-text": "#e2e8f0"
|
||||||
|
},
|
||||||
|
"violet": {
|
||||||
|
"--background": "#030128",
|
||||||
|
"--card": "#241f48",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#ffffff",
|
||||||
|
"--secondary-cta": "#131136",
|
||||||
|
"--accent": "#44358a",
|
||||||
|
"--background-accent": "#b597fe",
|
||||||
|
"--primary-cta-text": "#030128",
|
||||||
|
"--secondary-cta-text": "#d5d4f6"
|
||||||
|
},
|
||||||
|
"ruby": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#481f1f",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#ffffff",
|
||||||
|
"--secondary-cta": "#361311",
|
||||||
|
"--accent": "#51000b",
|
||||||
|
"--background-accent": "#ff2231",
|
||||||
|
"--primary-cta-text": "#280101",
|
||||||
|
"--secondary-cta-text": "#f6d4d4"
|
||||||
|
},
|
||||||
|
"emerald": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#1f4035",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#ffffff",
|
||||||
|
"--secondary-cta": "#0d2b1f",
|
||||||
|
"--accent": "#0d5238",
|
||||||
|
"--background-accent": "#10b981",
|
||||||
|
"--primary-cta-text": "#051a12",
|
||||||
|
"--secondary-cta-text": "#d4f6e8"
|
||||||
|
},
|
||||||
|
"indigo": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#1f1f40",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#ffffff",
|
||||||
|
"--secondary-cta": "#0d0d2b",
|
||||||
|
"--accent": "#3d2880",
|
||||||
|
"--background-accent": "#663cff",
|
||||||
|
"--primary-cta-text": "#0a051a",
|
||||||
|
"--secondary-cta-text": "#d4d4f6"
|
||||||
|
},
|
||||||
|
"forest": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#1a2f1d",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#ffffff",
|
||||||
|
"--secondary-cta": "#0d200f",
|
||||||
|
"--accent": "#1a3d1f",
|
||||||
|
"--background-accent": "#355e3b",
|
||||||
|
"--primary-cta-text": "#0a1a0c",
|
||||||
|
"--secondary-cta-text": "#d4f6d8"
|
||||||
|
},
|
||||||
|
"mint": {
|
||||||
|
"--background": "#000000",
|
||||||
|
"--card": "#1a2a1a",
|
||||||
|
"--foreground": "#ffffff",
|
||||||
|
"--primary-cta": "#ffffff",
|
||||||
|
"--secondary-cta": "#0d1a0d",
|
||||||
|
"--accent": "#2d4a2d",
|
||||||
|
"--background-accent": "#c1e1c1",
|
||||||
|
"--primary-cta-text": "#0a150a",
|
||||||
|
"--secondary-cta-text": "#e1f6e1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
cssOptions.json
Normal file
41
cssOptions.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"cards": {
|
||||||
|
"solid": "background: var(--color-card);",
|
||||||
|
"outline": "background: var(--color-card);\nborder: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);",
|
||||||
|
"gradient-mesh": "background:\n radial-gradient(at 0% 0%, color-mix(in srgb, var(--color-accent) 15%, transparent) 0px, transparent 50%),\n radial-gradient(at 100% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent) 0px, transparent 50%),\n radial-gradient(at 100% 100%, color-mix(in srgb, var(--color-accent) 20%, transparent) 0px, transparent 50%),\n radial-gradient(at 0% 100%, color-mix(in srgb, var(--color-accent) 12%, transparent) 0px, transparent 50%),\n var(--color-card);",
|
||||||
|
"gradient-radial": "background: radial-gradient(circle at center, color-mix(in srgb, var(--color-card) 100%, var(--color-accent) 20%) 0%, var(--color-card) 90%);",
|
||||||
|
"inset": "background: color-mix(in srgb, var(--color-card) 95%, var(--color-accent) 5%);\nbox-shadow:\n inset 2px 2px 4px color-mix(in srgb, var(--color-foreground) 8%, transparent),\n inset -2px -2px 4px color-mix(in srgb, var(--color-background) 20%, transparent);",
|
||||||
|
"glass-elevated": "backdrop-filter: blur(8px);\nbackground: linear-gradient(to bottom right, color-mix(in srgb, var(--color-card) 80%, transparent), color-mix(in srgb, var(--color-card) 40%, transparent));\nbox-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);\nborder: 1px solid var(--color-card);",
|
||||||
|
"glass-depth": "background: color-mix(in srgb, var(--color-card) 80%, transparent);\nbackdrop-filter: blur(14px);\nbox-shadow:\n inset 0 0 20px 0 color-mix(in srgb, var(--color-accent) 7.5%, transparent);\nborder: 1px solid color-mix(in srgb, var(--color-accent) 7.5%, transparent);",
|
||||||
|
"gradient-bordered": "background: linear-gradient(180deg, color-mix(in srgb, var(--color-card) 100%, var(--color-accent) 5%) -35%, var(--color-card) 65%);\nbox-shadow: 0px 0px 10px 4px color-mix(in srgb, var(--color-accent) 4%, transparent);\nborder: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);",
|
||||||
|
"layered-gradient": "background:\n linear-gradient(color-mix(in srgb, var(--color-accent) 6%, transparent) 0%, transparent 59.26%),\n linear-gradient(var(--color-card) 0%, var(--color-card) 100%),\n var(--color-card);\nbox-shadow:\n 20px 18px 7px color-mix(in srgb, var(--color-accent) 0%, transparent),\n 2px 2px 2px color-mix(in srgb, var(--color-accent) 6.5%, transparent),\n 1px 1px 2px color-mix(in srgb, var(--color-accent) 2%, transparent);\nborder: 2px solid var(--color-secondary-cta);",
|
||||||
|
"soft-shadow": "background: var(--color-card);\nbox-shadow: color-mix(in srgb, var(--color-accent) 10%, transparent) 0px 0.706592px 0.706592px -0.666667px, color-mix(in srgb, var(--color-accent) 8%, transparent) 0px 1.80656px 1.80656px -1.33333px, color-mix(in srgb, var(--color-accent) 7%, transparent) 0px 3.62176px 3.62176px -2px, color-mix(in srgb, var(--color-accent) 7%, transparent) 0px 6.8656px 6.8656px -2.66667px, color-mix(in srgb, var(--color-accent) 5%, transparent) 0px 13.6468px 13.6468px -3.33333px, color-mix(in srgb, var(--color-accent) 2%, transparent) 0px 30px 30px -4px, var(--color-background) 0px 3px 1px 0px inset;",
|
||||||
|
"subtle-shadow": "background: var(--color-card);\nbox-shadow: color-mix(in srgb, var(--color-foreground) 5%, transparent) 0px 4px 32px 0px;",
|
||||||
|
"elevated-border": "background: linear-gradient(180deg, color-mix(in srgb, var(--color-card) 100%, var(--color-foreground) 3%) 0%, var(--color-card) 100%);\nbox-shadow: 0 1px 0 0 color-mix(in srgb, var(--color-foreground) 8%, transparent), 0 4px 6px -1px color-mix(in srgb, var(--color-foreground) 5%, transparent), 0 10px 15px -3px color-mix(in srgb, var(--color-foreground) 4%, transparent);\nborder: 1px solid color-mix(in srgb, var(--color-foreground) 6%, transparent);",
|
||||||
|
"inner-glow": "background: var(--color-card);\nbox-shadow: inset 0 0 30px 0 color-mix(in srgb, var(--color-foreground) 4%, transparent), inset 0 1px 0 0 color-mix(in srgb, var(--color-foreground) 8%, transparent), 0 4px 12px -4px color-mix(in srgb, var(--color-foreground) 8%, transparent);",
|
||||||
|
"spotlight": "background:\n radial-gradient(ellipse at 0% 0%, color-mix(in srgb, var(--color-accent) 20%, transparent) 0%, transparent 50%),\n var(--color-card);\nbox-shadow: inset 1px 1px 0 0 color-mix(in srgb, var(--color-foreground) 10%, transparent), 0 4px 16px -4px color-mix(in srgb, var(--color-foreground) 10%, transparent);"
|
||||||
|
},
|
||||||
|
"primaryButtons": {
|
||||||
|
"gradient": "background: linear-gradient(to bottom, color-mix(in srgb, var(--color-primary-cta) 75%, transparent), var(--color-primary-cta));\nbox-shadow: color-mix(in srgb, var(--color-background) 25%, transparent) 0px 1px 1px 0px inset, color-mix(in srgb, var(--color-primary-cta) 15%, transparent) 3px 3px 3px 0px;",
|
||||||
|
"shadow": "background: var(--color-primary-cta);\nbox-shadow: 2.10837px 3.16256px 9.48767px color-mix(in srgb, var(--color-primary-cta) 40%, transparent);",
|
||||||
|
"flat": "background: var(--color-primary-cta);",
|
||||||
|
"radial-glow": "background:\n radial-gradient(circle at 0% 0%, color-mix(in srgb, var(--color-background) 32.5%, transparent) 0%, transparent 45%),\n radial-gradient(circle at 100% 100%, color-mix(in srgb, var(--color-background) 32.5%, transparent) 0%, transparent 45%),\n var(--color-primary-cta);\nbox-shadow: 2.10837px 3.16256px 9.48767px color-mix(in srgb, var(--color-accent) 30%, transparent);",
|
||||||
|
"diagonal-gradient": "background: linear-gradient(to bottom right, color-mix(in srgb, var(--color-primary-cta) 80%, transparent), var(--color-foreground));\nbox-shadow: 2.10837px 3.16256px 9.48767px color-mix(in srgb, var(--color-accent) 30%, transparent);",
|
||||||
|
"double-inset": "background: var(--color-primary-cta);\nbox-shadow: color-mix(in srgb, var(--color-background) 15%, transparent) 0px 4px 10px 0px inset, color-mix(in srgb, var(--color-background) 15%, transparent) 0px -4px 8px 0px inset;",
|
||||||
|
"primary-glow": "background: var(--color-primary-cta);\nbox-shadow: color-mix(in srgb, var(--color-background) 20%, transparent) 0px 3px 1px 0px inset, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 0.839802px 0.503881px -0.3125px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 1.99048px 1.19429px -0.625px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 3.63084px 2.1785px -0.9375px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 6.03627px 3.62176px -1.25px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 9.74808px 5.84885px -1.5625px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 15.9566px 9.57398px -1.875px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 27.4762px 16.4857px -2.1875px, color-mix(in srgb, var(--color-primary-cta) 13%, transparent) 0px 50px 30px -2.5px;",
|
||||||
|
"inset-glow": "background: linear-gradient(180deg, color-mix(in srgb, var(--color-primary-cta) 65%, var(--color-background)) -35%, var(--color-primary-cta) 65%);\nbox-shadow: 0 10px 18px -7px color-mix(in srgb, var(--color-background) 50%, transparent), inset 0 1px 0 0 color-mix(in srgb, var(--color-foreground) 15%, transparent);\nborder: 1px solid color-mix(in srgb, var(--color-foreground) 20%, transparent);",
|
||||||
|
"soft-glow": "background: radial-gradient(ellipse at 50% -20%, color-mix(in srgb, var(--color-primary-cta) 70%, var(--color-foreground)) 0%, var(--color-primary-cta) 70%);\nbox-shadow: 0 8px 24px -6px color-mix(in srgb, var(--color-primary-cta) 35%, transparent), inset 0 1px 0 0 color-mix(in srgb, var(--color-foreground) 20%, transparent);",
|
||||||
|
"glass-shimmer": "background: linear-gradient(165deg, color-mix(in srgb, var(--color-primary-cta) 85%, var(--color-foreground)) 0%, var(--color-primary-cta) 40%, color-mix(in srgb, var(--color-primary-cta) 90%, var(--color-background)) 100%);\nbox-shadow: inset 0 1px 1px 0 color-mix(in srgb, var(--color-foreground) 25%, transparent), inset 0 -1px 1px 0 color-mix(in srgb, var(--color-background) 15%, transparent), 0 4px 12px -2px color-mix(in srgb, var(--color-primary-cta) 25%, transparent);",
|
||||||
|
"neon-outline": "background: var(--color-primary-cta);\nbox-shadow: 0 0 5px color-mix(in srgb, var(--color-accent) 50%, transparent), 0 0 15px color-mix(in srgb, var(--color-accent) 30%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent) 15%, transparent), inset 0 0 8px color-mix(in srgb, var(--color-accent) 10%, transparent);",
|
||||||
|
"lifted": "background: linear-gradient(180deg, color-mix(in srgb, var(--color-primary-cta) 95%, var(--color-foreground)) 0%, var(--color-primary-cta) 50%, color-mix(in srgb, var(--color-primary-cta) 95%, var(--color-background)) 100%);\nbox-shadow: inset 0 2px 3px 0 color-mix(in srgb, var(--color-foreground) 20%, transparent), inset 0 -2px 3px 0 color-mix(in srgb, var(--color-background) 25%, transparent), 0 2px 4px -1px color-mix(in srgb, var(--color-background) 40%, transparent);",
|
||||||
|
"depth-layers": "background: var(--color-primary-cta);\nbox-shadow: 0 1px 2px color-mix(in srgb, var(--color-primary-cta) 20%, transparent), 0 2px 4px color-mix(in srgb, var(--color-primary-cta) 20%, transparent), 0 4px 8px color-mix(in srgb, var(--color-primary-cta) 15%, transparent), 0 8px 16px color-mix(in srgb, var(--color-primary-cta) 10%, transparent), 0 16px 32px color-mix(in srgb, var(--color-primary-cta) 5%, transparent);",
|
||||||
|
"accent-edge": "background: linear-gradient(180deg, var(--color-primary-cta) 0%, color-mix(in srgb, var(--color-primary-cta) 90%, var(--color-background)) 100%);\nbox-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, transparent), 0 4px 12px -2px color-mix(in srgb, var(--color-accent) 35%, transparent), inset 0 1px 0 0 color-mix(in srgb, var(--color-foreground) 20%, transparent);",
|
||||||
|
"metallic": "background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary-cta) 80%, var(--color-foreground)) 0%, var(--color-primary-cta) 25%, color-mix(in srgb, var(--color-primary-cta) 90%, var(--color-background)) 50%, var(--color-primary-cta) 75%, color-mix(in srgb, var(--color-primary-cta) 85%, var(--color-foreground)) 100%);\nbox-shadow: inset 0 1px 0 0 color-mix(in srgb, var(--color-foreground) 30%, transparent), 0 3px 8px -2px color-mix(in srgb, var(--color-background) 50%, transparent);"
|
||||||
|
},
|
||||||
|
"secondaryButtons": {
|
||||||
|
"glass": "backdrop-filter: blur(8px);\nbackground: linear-gradient(to bottom right, color-mix(in srgb, var(--color-secondary-cta) 80%, transparent), var(--color-secondary-cta));\nbox-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);\nborder: 1px solid var(--color-secondary-cta);",
|
||||||
|
"solid": "background: var(--color-secondary-cta);",
|
||||||
|
"layered": "background:\n linear-gradient(color-mix(in srgb, var(--color-accent) 5%, transparent) 0%, transparent 59.26%),\n linear-gradient(var(--color-secondary-cta), var(--color-secondary-cta)),\n linear-gradient(var(--color-secondary-cta), var(--color-secondary-cta)),\n linear-gradient(color-mix(in srgb, var(--color-accent) 5%, transparent) 0%, transparent 59.26%),\n linear-gradient(color-mix(in srgb, var(--color-secondary-cta) 60%, transparent), color-mix(in srgb, var(--color-secondary-cta) 60%, transparent)),\n var(--color-secondary-cta);\nbox-shadow:\n 2.10837px 3.16256px 9.48767px color-mix(in srgb, var(--color-accent) 10%, transparent);\nborder: 1px solid var(--color-secondary-cta);",
|
||||||
|
"radial-glow": "background:\n radial-gradient(circle at 0% 0%, color-mix(in srgb, var(--color-accent) 15%, transparent) 0%, transparent 40%),\n radial-gradient(circle at 100% 100%, color-mix(in srgb, var(--color-accent) 15%, transparent) 0%, transparent 40%),\n var(--color-secondary-cta);\nbox-shadow: 2.10837px 3.16256px 9.48767px color-mix(in srgb, var(--color-accent) 10%, transparent);"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'react-hooks/static-components': 'off',
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
237
fontThemes.json
Normal file
237
fontThemes.json
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
{
|
||||||
|
"singleFonts": {
|
||||||
|
"interTight": {
|
||||||
|
"name": "Inter Tight",
|
||||||
|
"import": "import { Inter_Tight } from \"next/font/google\";",
|
||||||
|
"initialization": "const interTight = Inter_Tight({\n variable: \"--font-inter-tight\",\n subsets: [\"latin\"],\n weight: [\"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\"],\n});",
|
||||||
|
"className": "${interTight.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-inter-tight), sans-serif;"
|
||||||
|
},
|
||||||
|
"inter": {
|
||||||
|
"name": "Inter",
|
||||||
|
"import": "import { Inter } from \"next/font/google\";",
|
||||||
|
"initialization": "const inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${inter.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-inter), sans-serif;"
|
||||||
|
},
|
||||||
|
"poppins": {
|
||||||
|
"name": "Poppins",
|
||||||
|
"import": "import { Poppins } from \"next/font/google\";",
|
||||||
|
"initialization": "const poppins = Poppins({\n variable: \"--font-poppins\",\n subsets: [\"latin\"],\n weight: [\"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\"],\n});",
|
||||||
|
"className": "${poppins.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-poppins), sans-serif;"
|
||||||
|
},
|
||||||
|
"montserrat": {
|
||||||
|
"name": "Montserrat",
|
||||||
|
"import": "import { Montserrat } from \"next/font/google\";",
|
||||||
|
"initialization": "const montserrat = Montserrat({\n variable: \"--font-montserrat\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${montserrat.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-montserrat), sans-serif;"
|
||||||
|
},
|
||||||
|
"roboto": {
|
||||||
|
"name": "Roboto",
|
||||||
|
"import": "import { Roboto } from \"next/font/google\";",
|
||||||
|
"initialization": "const roboto = Roboto({\n variable: \"--font-roboto\",\n subsets: [\"latin\"],\n weight: [\"100\", \"300\", \"400\", \"500\", \"700\", \"900\"],\n});",
|
||||||
|
"className": "${roboto.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-roboto), sans-serif;"
|
||||||
|
},
|
||||||
|
"openSans": {
|
||||||
|
"name": "Open Sans",
|
||||||
|
"import": "import { Open_Sans } from \"next/font/google\";",
|
||||||
|
"initialization": "const openSans = Open_Sans({\n variable: \"--font-open-sans\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${openSans.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-open-sans), sans-serif;"
|
||||||
|
},
|
||||||
|
"lato": {
|
||||||
|
"name": "Lato",
|
||||||
|
"import": "import { Lato } from \"next/font/google\";",
|
||||||
|
"initialization": "const lato = Lato({\n variable: \"--font-lato\",\n subsets: [\"latin\"],\n weight: [\"100\", \"300\", \"400\", \"700\", \"900\"],\n});",
|
||||||
|
"className": "${lato.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-lato), sans-serif;"
|
||||||
|
},
|
||||||
|
"dmSans": {
|
||||||
|
"name": "DM Sans",
|
||||||
|
"import": "import { DM_Sans } from \"next/font/google\";",
|
||||||
|
"initialization": "const dmSans = DM_Sans({\n variable: \"--font-dm-sans\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${dmSans.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-dm-sans), sans-serif;"
|
||||||
|
},
|
||||||
|
"manrope": {
|
||||||
|
"name": "Manrope",
|
||||||
|
"import": "import { Manrope } from \"next/font/google\";",
|
||||||
|
"initialization": "const manrope = Manrope({\n variable: \"--font-manrope\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${manrope.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-manrope), sans-serif;"
|
||||||
|
},
|
||||||
|
"sourceSans3": {
|
||||||
|
"name": "Source Sans 3",
|
||||||
|
"import": "import { Source_Sans_3 } from \"next/font/google\";",
|
||||||
|
"initialization": "const sourceSans3 = Source_Sans_3({\n variable: \"--font-source-sans-3\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${sourceSans3.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-source-sans-3), sans-serif;"
|
||||||
|
},
|
||||||
|
"publicSans": {
|
||||||
|
"name": "Public Sans",
|
||||||
|
"import": "import { Public_Sans } from \"next/font/google\";",
|
||||||
|
"initialization": "const publicSans = Public_Sans({\n variable: \"--font-public-sans\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${publicSans.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-public-sans), sans-serif;"
|
||||||
|
},
|
||||||
|
"mulish": {
|
||||||
|
"name": "Mulish",
|
||||||
|
"import": "import { Mulish } from \"next/font/google\";",
|
||||||
|
"initialization": "const mulish = Mulish({\n variable: \"--font-mulish\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${mulish.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-mulish), sans-serif;"
|
||||||
|
},
|
||||||
|
"nunito": {
|
||||||
|
"name": "Nunito",
|
||||||
|
"import": "import { Nunito } from \"next/font/google\";",
|
||||||
|
"initialization": "const nunito = Nunito({\n variable: \"--font-nunito\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${nunito.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-nunito), sans-serif;"
|
||||||
|
},
|
||||||
|
"nunitoSans": {
|
||||||
|
"name": "Nunito Sans",
|
||||||
|
"import": "import { Nunito_Sans } from \"next/font/google\";",
|
||||||
|
"initialization": "const nunitoSans = Nunito_Sans({\n variable: \"--font-nunito-sans\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${nunitoSans.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-nunito-sans), sans-serif;"
|
||||||
|
},
|
||||||
|
"raleway": {
|
||||||
|
"name": "Raleway",
|
||||||
|
"import": "import { Raleway } from \"next/font/google\";",
|
||||||
|
"initialization": "const raleway = Raleway({\n variable: \"--font-raleway\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${raleway.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-raleway), sans-serif;"
|
||||||
|
},
|
||||||
|
"archivo": {
|
||||||
|
"name": "Archivo",
|
||||||
|
"import": "import { Archivo } from \"next/font/google\";",
|
||||||
|
"initialization": "const archivo = Archivo({\n variable: \"--font-archivo\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${archivo.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-archivo), sans-serif;"
|
||||||
|
},
|
||||||
|
"figtree": {
|
||||||
|
"name": "Figtree",
|
||||||
|
"import": "import { Figtree } from \"next/font/google\";",
|
||||||
|
"initialization": "const figtree = Figtree({\n variable: \"--font-figtree\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"className": "${figtree.variable}",
|
||||||
|
"cssVariable": "font-family: var(--font-figtree), sans-serif;"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fontPairings": {
|
||||||
|
"interOpenSans": {
|
||||||
|
"name": "Inter + Open Sans",
|
||||||
|
"description": "Neutral headings with friendly body. Clean and approachable.",
|
||||||
|
"headingFont": "inter",
|
||||||
|
"bodyFont": "openSans",
|
||||||
|
"imports": "import { Inter } from \"next/font/google\";\nimport { Open_Sans } from \"next/font/google\";",
|
||||||
|
"initializations": "const inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});\n\nconst openSans = Open_Sans({\n variable: \"--font-open-sans\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${inter.variable} ${openSans.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-open-sans), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-inter), sans-serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-open-sans), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-inter), sans-serif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dmSansInter": {
|
||||||
|
"name": "DM Sans + Inter",
|
||||||
|
"description": "Modern geometric headings with neutral body. Contemporary and clean.",
|
||||||
|
"headingFont": "dmSans",
|
||||||
|
"bodyFont": "inter",
|
||||||
|
"imports": "import { DM_Sans } from \"next/font/google\";\nimport { Inter } from \"next/font/google\";",
|
||||||
|
"initializations": "const dmSans = DM_Sans({\n variable: \"--font-dm-sans\",\n subsets: [\"latin\"],\n});\n\nconst inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${dmSans.variable} ${inter.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-inter), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-dm-sans), sans-serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-inter), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-dm-sans), sans-serif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manropeDmSans": {
|
||||||
|
"name": "Manrope + DM Sans",
|
||||||
|
"description": "Geometric headings with clean body. Modern and professional.",
|
||||||
|
"headingFont": "manrope",
|
||||||
|
"bodyFont": "dmSans",
|
||||||
|
"imports": "import { Manrope } from \"next/font/google\";\nimport { DM_Sans } from \"next/font/google\";",
|
||||||
|
"initializations": "const manrope = Manrope({\n variable: \"--font-manrope\",\n subsets: [\"latin\"],\n});\n\nconst dmSans = DM_Sans({\n variable: \"--font-dm-sans\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${manrope.variable} ${dmSans.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-dm-sans), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-manrope), sans-serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-dm-sans), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-manrope), sans-serif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publicSansInter": {
|
||||||
|
"name": "Public Sans + Inter",
|
||||||
|
"description": "Government-inspired headings with neutral body. Professional and trustworthy.",
|
||||||
|
"headingFont": "publicSans",
|
||||||
|
"bodyFont": "inter",
|
||||||
|
"imports": "import { Public_Sans } from \"next/font/google\";\nimport { Inter } from \"next/font/google\";",
|
||||||
|
"initializations": "const publicSans = Public_Sans({\n variable: \"--font-public-sans\",\n subsets: [\"latin\"],\n});\n\nconst inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${publicSans.variable} ${inter.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-inter), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-public-sans), sans-serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-inter), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-public-sans), sans-serif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mulishInter": {
|
||||||
|
"name": "Mulish + Inter",
|
||||||
|
"description": "Minimal headings with neutral body. Clean and modern.",
|
||||||
|
"headingFont": "mulish",
|
||||||
|
"bodyFont": "inter",
|
||||||
|
"imports": "import { Mulish } from \"next/font/google\";\nimport { Inter } from \"next/font/google\";",
|
||||||
|
"initializations": "const mulish = Mulish({\n variable: \"--font-mulish\",\n subsets: [\"latin\"],\n});\n\nconst inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${mulish.variable} ${inter.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-inter), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-mulish), sans-serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-inter), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-mulish), sans-serif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"montserratInter": {
|
||||||
|
"name": "Montserrat + Inter",
|
||||||
|
"description": "Geometric sans-serif headings with neutral body. Popular and reliable.",
|
||||||
|
"headingFont": "montserrat",
|
||||||
|
"bodyFont": "inter",
|
||||||
|
"imports": "import { Montserrat } from \"next/font/google\";\nimport { Inter } from \"next/font/google\";",
|
||||||
|
"initializations": "const montserrat = Montserrat({\n variable: \"--font-montserrat\",\n subsets: [\"latin\"],\n});\n\nconst inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${montserrat.variable} ${inter.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-inter), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-montserrat), sans-serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-inter), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-montserrat), sans-serif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libreBaskervilleInter": {
|
||||||
|
"name": "Libre Baskerville + Inter",
|
||||||
|
"description": "Classic serif headings with neutral body. Elegant and readable.",
|
||||||
|
"headingFont": "libreBaskerville",
|
||||||
|
"bodyFont": "inter",
|
||||||
|
"imports": "import { Libre_Baskerville } from \"next/font/google\";\nimport { Inter } from \"next/font/google\";",
|
||||||
|
"initializations": "const libreBaskerville = Libre_Baskerville({\n variable: \"--font-libre-baskerville\",\n subsets: [\"latin\"],\n weight: [\"400\", \"700\"],\n});\n\nconst inter = Inter({\n variable: \"--font-inter\",\n subsets: [\"latin\"],\n});",
|
||||||
|
"classNames": "${libreBaskerville.variable} ${inter.variable}",
|
||||||
|
"globalsCss": {
|
||||||
|
"instructions": "Update the font-family property within the existing CSS rules in globals.css (@layer base section)",
|
||||||
|
"bodyRule": "body {\n /* ... existing properties ... */\n font-family: var(--font-inter), sans-serif;\n}",
|
||||||
|
"headingsRule": "h1, h2, h3, h4, h5, h6 {\n font-family: var(--font-libre-baskerville), serif;\n}",
|
||||||
|
"bodyFontFamily": "var(--font-inter), sans-serif",
|
||||||
|
"headingsFontFamily": "var(--font-libre-baskerville), serif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
index.html
Normal file
18
index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vero Italiano | Authentic Italian Beverages</title>
|
||||||
|
<meta property="og:title" content="Vero Italiano | Authentic Italian Beverages" />
|
||||||
|
<meta name="twitter:title" content="Vero Italiano | Authentic Italian Beverages" />
|
||||||
|
<meta name="description" content="Experience the true essence of Italy with Vero Italiano's premium, handcrafted beverages, sourced from Tuscany." />
|
||||||
|
<meta property="og:description" content="Experience the true essence of Italy with Vero Italiano's premium, handcrafted beverages, sourced from Tuscany." />
|
||||||
|
<meta name="twitter:description" content="Experience the true essence of Italy with Vero Italiano's premium, handcrafted beverages, sourced from Tuscany." />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "webild-components",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.app.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rive-app/react-canvas": "^4.28.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"embla-carousel": "^8.6.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"motion": "^12.38.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
2232
pnpm-lock.yaml
generated
Normal file
2232
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
1506
registry.json
Normal file
1506
registry.json
Normal file
File diff suppressed because it is too large
Load Diff
306
src/App.tsx
Normal file
306
src/App.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import AboutMediaOverlay from '@/components/sections/about/AboutMediaOverlay';
|
||||||
|
import ContactCenter from '@/components/sections/contact/ContactCenter';
|
||||||
|
import FaqSplitMedia from '@/components/sections/faq/FaqSplitMedia';
|
||||||
|
import FeaturesDetailedSteps from '@/components/sections/features/FeaturesDetailedSteps';
|
||||||
|
import FooterBrandReveal from '@/components/sections/footer/FooterBrandReveal';
|
||||||
|
import HeroBrandCarousel from '@/components/sections/hero/HeroBrandCarousel';
|
||||||
|
import MetricsGradientCards from '@/components/sections/metrics/MetricsGradientCards';
|
||||||
|
import NavbarCentered from '@/components/ui/NavbarCentered';
|
||||||
|
import ProductQuantityCards from '@/components/sections/product/ProductQuantityCards';
|
||||||
|
import TestimonialOverlayCards from '@/components/sections/testimonial/TestimonialOverlayCards';
|
||||||
|
import { Award, History, Users } from "lucide-react";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="nav" data-section="nav">
|
||||||
|
<NavbarCentered
|
||||||
|
logo="Vero Italiano"
|
||||||
|
navItems={[
|
||||||
|
{
|
||||||
|
name: "Our Heritage",
|
||||||
|
href: "#about",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collection",
|
||||||
|
href: "#products",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Quality",
|
||||||
|
href: "#features",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reviews",
|
||||||
|
href: "#testimonials",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
ctaButton={{
|
||||||
|
text: "Shop Now",
|
||||||
|
href: "#products",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hero" data-section="hero">
|
||||||
|
<HeroBrandCarousel
|
||||||
|
brand="Vero Italiano"
|
||||||
|
description="Authentic beverages crafted with tradition, directly from the heart of Italy to your table."
|
||||||
|
primaryButton={{
|
||||||
|
text: "View Collection",
|
||||||
|
href: "#products",
|
||||||
|
}}
|
||||||
|
secondaryButton={{
|
||||||
|
text: "Our Story",
|
||||||
|
href: "#about",
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/rollcake-with-raspberries-platter-with-drink_114579-16506.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/italian-villa-terrace-with-scenic-view-outdoor-seating_23-2151986078.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/glass-lemonade-with-lemon-slices-grapefruits-marble-surface_114579-24824.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/taking-confiture-with-spoon-tea-table_114579-3643.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/rural-landscape-with-farmhouse-meadow-generative-ai_188544-7847.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/clear-wine-glass-brown-wooden-table_209848-77.jpg",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="about" data-section="about">
|
||||||
|
<AboutMediaOverlay
|
||||||
|
tag="Our Heritage"
|
||||||
|
title="Centuries of Craftsmanship"
|
||||||
|
description="Born in the rolling hills of Tuscany, our beverages are a tribute to time-honored recipes, using only the finest local ingredients to capture the true essence of Italy."
|
||||||
|
imageSrc="http://img.b2bpic.net/free-photo/portrait-tattooed-bearded-hipster-male-manufacturer-presenting-beer-microbrewery_613910-8080.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="products" data-section="products">
|
||||||
|
<ProductQuantityCards
|
||||||
|
tag="Collection"
|
||||||
|
title="Authentic Italian Selection"
|
||||||
|
description="Explore our curated range of artisanal beverages."
|
||||||
|
products={[
|
||||||
|
{
|
||||||
|
name: "Sparkling Aqua",
|
||||||
|
price: "$12.00",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/starfruit-juice-black-background_23-2148227511.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Espresso Liqueur",
|
||||||
|
price: "$45.00",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/top-view-sustainably-produced-alcoholic-beverage_23-2150162970.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blood Orange Nectar",
|
||||||
|
price: "$15.00",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/moody-fruit-still-life_23-2148139598.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Herbal Infusion",
|
||||||
|
price: "$18.00",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/gold-wine-bottle-label-with-design-space_53876-167287.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Classic Prosecco",
|
||||||
|
price: "$35.00",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/front-view-glass-champagne-with-bottle-light-drink-alcohol-photo-color-champagne-new-year_140725-93008.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Lemon Artisanal Soda",
|
||||||
|
price: "$9.00",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/delicious-kombucha-bottle-flowers_23-2149757699.jpg",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="features" data-section="features">
|
||||||
|
<FeaturesDetailedSteps
|
||||||
|
tag="Quality"
|
||||||
|
title="Why Vero Italiano?"
|
||||||
|
description="Our commitment to excellence shines in every drop."
|
||||||
|
steps={[
|
||||||
|
{
|
||||||
|
tag: "01",
|
||||||
|
title: "Sourced Locally",
|
||||||
|
subtitle: "From Italy's best farms",
|
||||||
|
description: "Every ingredient is sourced from sustainable, family-owned estates across Italy.",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/new-year-party-arrangement-with-copy-space_23-2149181130.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "02",
|
||||||
|
title: "Traditional Process",
|
||||||
|
subtitle: "Time-tested methods",
|
||||||
|
description: "We preserve age-old techniques to ensure the most authentic flavor profiles.",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/beautiful-autumn-lifestyle_23-2151867726.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "03",
|
||||||
|
title: "Pure Quality",
|
||||||
|
subtitle: "No additives",
|
||||||
|
description: "We believe in transparency and purity, using zero artificial preservatives or colors.",
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/autumn-decoration-with-wine-pot_23-2147709565.jpg",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="metrics" data-section="metrics">
|
||||||
|
<MetricsGradientCards
|
||||||
|
tag="Impact"
|
||||||
|
title="Proven Quality"
|
||||||
|
description="Our dedication to quality has resonated globally."
|
||||||
|
metrics={[
|
||||||
|
{
|
||||||
|
value: "150+",
|
||||||
|
title: "Years of History",
|
||||||
|
description: "Founded in 1872",
|
||||||
|
icon: History,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "50k",
|
||||||
|
title: "Happy Customers",
|
||||||
|
description: "Worldwide reach",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "20+",
|
||||||
|
title: "Awards Won",
|
||||||
|
description: "International acclaim",
|
||||||
|
icon: Award,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="testimonials" data-section="testimonials">
|
||||||
|
<TestimonialOverlayCards
|
||||||
|
tag="Reviews"
|
||||||
|
title="What Our Drinkers Say"
|
||||||
|
description="Experience the authenticity our customers love."
|
||||||
|
testimonials={[
|
||||||
|
{
|
||||||
|
name: "Marco Rossi",
|
||||||
|
role: "Sommelier",
|
||||||
|
company: "VinoVino",
|
||||||
|
rating: 5,
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/portrait-young-brunette-woman-sitting-with-coffee-using-smartphone-cafe-chatting_1258-199048.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Elena Bianchi",
|
||||||
|
role: "Food Blogger",
|
||||||
|
company: "EatItaly",
|
||||||
|
rating: 5,
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/confident-man-leaning-wall_23-2148401390.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Luca Verdi",
|
||||||
|
role: "Head Chef",
|
||||||
|
company: "Osteria D'Oro",
|
||||||
|
rating: 5,
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/smiling-middle-aged-businesswoman-sitting-floor_1262-1992.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sofia Ricci",
|
||||||
|
role: "Beverage Consultant",
|
||||||
|
company: "Mixologists Inc",
|
||||||
|
rating: 5,
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/indoor-portrait-confident-thoughtful-male-freelancer-keeps-hand-chin-tries-decide-what_273609-8639.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Giovanni Ferro",
|
||||||
|
role: "Food Critic",
|
||||||
|
company: "TasteDaily",
|
||||||
|
rating: 5,
|
||||||
|
imageSrc: "http://img.b2bpic.net/free-photo/modern-woman-taking-selfie_23-2148020485.jpg",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="faq" data-section="faq">
|
||||||
|
<FaqSplitMedia
|
||||||
|
tag="Support"
|
||||||
|
title="Frequently Asked Questions"
|
||||||
|
description="Have questions about our products or process?"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
question: "Are your ingredients organic?",
|
||||||
|
answer: "Yes, we prioritize organic farming practices for all our fruit and herbal sources.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Where are your products produced?",
|
||||||
|
answer: "Every bottle is produced in our historic facility in Tuscany, Italy.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Do you ship internationally?",
|
||||||
|
answer: "Yes, we ship globally to bring a taste of Italy to your doorstep.",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
imageSrc="http://img.b2bpic.net/free-photo/village-landscape_1182-845.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="contact" data-section="contact">
|
||||||
|
<ContactCenter
|
||||||
|
tag="Stay Connected"
|
||||||
|
title="Join Our Newsletter"
|
||||||
|
description="Get exclusive updates on new releases, seasonal offers, and Italian heritage stories."
|
||||||
|
inputPlaceholder="Enter your email address"
|
||||||
|
buttonText="Subscribe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="footer" data-section="footer">
|
||||||
|
<FooterBrandReveal
|
||||||
|
brand="VERO ITALIANO"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Our Story",
|
||||||
|
href: "#about",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Shop",
|
||||||
|
href: "#products",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Quality",
|
||||||
|
href: "#features",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Contact Us",
|
||||||
|
href: "#contact",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Privacy Policy",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Terms of Service",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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-medium 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/10" />
|
||||||
|
|
||||||
|
<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-medium text-foreground truncate">{item.name}</h3>
|
||||||
|
<p className="shrink-0 text-base font-medium 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-medium 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/10" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-base font-medium text-foreground">Total</span>
|
||||||
|
<span className="text-base font-medium 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-medium 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-medium 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-medium 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-medium 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-medium text-foreground md:text-3xl">{name}</h2>
|
||||||
|
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-medium rounded text-secondary-cta-text">{ribbon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-full bg-foreground/10" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xl font-medium 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-medium 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-medium 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 };
|
||||||
84
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
84
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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 mx-auto w-content-width">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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-5 p-5 w-full md:w-4/10 2xl:w-3/10 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-xl font-medium">{item.title}</h3>
|
||||||
|
<p className="text-base leading-tight">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
{index < items.length - 1 && (
|
||||||
|
<div className="mt-5 border-b border-accent/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutFeaturesSplit;
|
||||||
70
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
70
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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-background/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-3 md:gap-1 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-3 px-3 py-1 text-sm card rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutMediaOverlay;
|
||||||
61
src/components/sections/about/AboutTestimonial.tsx
Normal file
61
src/components/sections/about/AboutTestimonial.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Quote } from "lucide-react";
|
||||||
|
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-8 md:px-12 md:py-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 py-8 md:py-5 h-full">
|
||||||
|
<span className="w-fit px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={quote}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-3xl md:text-4xl font-medium leading-tight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">{author}</span>
|
||||||
|
<span className="text-accent">•</span>
|
||||||
|
<span className="text-base text-foreground/75">{role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutTestimonial;
|
||||||
56
src/components/sections/about/AboutTextSplit.tsx
Normal file
56
src/components/sections/about/AboutTextSplit.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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-30 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"
|
||||||
|
tag="h2"
|
||||||
|
className="text-7xl font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 w-full md:w-1/2">
|
||||||
|
{descriptions.map((desc, index) => (
|
||||||
|
<TextAnimation
|
||||||
|
key={index}
|
||||||
|
text={desc}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-2xl leading-tight text-foreground/75"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-5">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full border-b border-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutTextSplit;
|
||||||
157
src/components/sections/blog/BlogMediaCards.tsx
Normal file
157
src/components/sections/blog/BlogMediaCards.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
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-5 p-5 rounded cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="card w-fit rounded px-2 py-0.5 text-xs mb-0.5">{item.category}</span>
|
||||||
|
|
||||||
|
<h3 className="text-2xl md:text-3xl font-medium leading-tight line-clamp-2">{item.title}</h3>
|
||||||
|
<p className="text-sm leading-tight opacity-75 line-clamp-2">{item.excerpt}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.authorImageSrc}
|
||||||
|
className="size-9 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{item.authorName}</span>
|
||||||
|
<span className="text-xs opacity-75">{item.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative aspect-square 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>
|
||||||
|
</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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BlogCardItem key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogMediaCards;
|
||||||
160
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
160
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
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-5 p-5 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-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="primary-button w-fit rounded px-2 py-0.5 text-xs text-primary-cta-text">
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-medium leading-tight mt-1">{item.title}</h3>
|
||||||
|
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.authorImageSrc}
|
||||||
|
className="size-9 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{item.authorName}</span>
|
||||||
|
<span className="text-xs opacity-75">{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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BlogCardItem key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogSimpleCards;
|
||||||
167
src/components/sections/blog/BlogTagCards.tsx
Normal file
167
src/components/sections/blog/BlogTagCards.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
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-5 p-5 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-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.authorImageSrc}
|
||||||
|
className="size-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span className="text-xs opacity-75">
|
||||||
|
{item.authorName} • {item.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.tags.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="primary-button rounded px-2 py-0.5 text-xs text-primary-cta-text"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BlogCardItem key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogTagCards;
|
||||||
94
src/components/sections/contact/ContactCenter.tsx
Normal file
94
src/components/sections/contact/ContactCenter.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import { sendContactEmail } from "@/lib/api/email";
|
||||||
|
|
||||||
|
const ContactCenter = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputPlaceholder,
|
||||||
|
buttonText,
|
||||||
|
termsText,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputPlaceholder: string;
|
||||||
|
buttonText: string;
|
||||||
|
termsText?: string;
|
||||||
|
onSubmit?: (email: string) => void;
|
||||||
|
}) => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ email });
|
||||||
|
onSubmit?.(email);
|
||||||
|
setEmail("");
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex items-center justify-center py-20 card rounded"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full md:w-1/2 gap-3 px-5">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-5xl md:text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-8/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 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"
|
||||||
|
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{termsText && (
|
||||||
|
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
|
||||||
|
{termsText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactCenter;
|
||||||
47
src/components/sections/contact/ContactCta.tsx
Normal file
47
src/components/sections/contact/ContactCta.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
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">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex items-center justify-center py-20 px-5 md:px-10 card rounded"
|
||||||
|
>
|
||||||
|
<div className="w-full md:w-3/4 flex flex-col items-center gap-3">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={text}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-4xl md:text-5xl font-medium text-center leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactCta;
|
||||||
105
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
105
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
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;
|
||||||
|
termsText?: string;
|
||||||
|
onSubmit?: (email: string) => void;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const ContactSplitEmail = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputPlaceholder,
|
||||||
|
buttonText,
|
||||||
|
termsText,
|
||||||
|
onSubmit,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: ContactSplitEmailProps) => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ email });
|
||||||
|
onSubmit?.(email);
|
||||||
|
setEmail("");
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
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-3 px-5">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-5xl md:text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-8/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 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"
|
||||||
|
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{termsText && (
|
||||||
|
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
|
||||||
|
{termsText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-100 md:h-auto card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactSplitEmail;
|
||||||
144
src/components/sections/contact/ContactSplitForm.tsx
Normal file
144
src/components/sections/contact/ContactSplitForm.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
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 handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
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 {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 gap-5"
|
||||||
|
>
|
||||||
|
<div className="p-5 md:p-10 card rounded">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-4xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-sm md:text-base leading-tight 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"
|
||||||
|
className="w-full h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer primary-button"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-100 md:h-auto card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactSplitForm;
|
||||||
109
src/components/sections/faq/FaqSimple.tsx
Normal file
109
src/components/sections/faq/FaqSimple.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="card rounded p-3 md:p-5 cursor-pointer select-none"
|
||||||
|
onClick={() => handleToggle(index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-lg md:text-xl font-medium leading-tight">{item.question}</h3>
|
||||||
|
<div className="flex shrink-0 items-center justify-center size-8 rounded primary-button">
|
||||||
|
<Plus
|
||||||
|
className={cls(
|
||||||
|
"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 md:pt-0 text-sm md:text-base leading-relaxed opacity-75">{item.answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqSimple;
|
||||||
130
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
130
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
|
||||||
|
className="md:col-span-3 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="card rounded p-3 md:p-5 cursor-pointer select-none"
|
||||||
|
onClick={() => handleToggle(index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
|
||||||
|
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 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-2 text-sm leading-relaxed opacity-75">{item.answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqSplitMedia;
|
||||||
124
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
124
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
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}
|
||||||
|
className="secondary-button rounded p-3 md:p-5 cursor-pointer select-none"
|
||||||
|
onClick={() => handleToggle(index)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
|
||||||
|
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 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-2 text-sm leading-relaxed opacity-75">{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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="card rounded p-3 md:p-5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 md:gap-5">
|
||||||
|
<div className="flex flex-1 flex-col gap-3 md:gap-5">
|
||||||
|
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
|
||||||
|
</div>
|
||||||
|
{secondColumn.length > 0 && (
|
||||||
|
<div className="flex flex-1 flex-col gap-3 md:gap-5">
|
||||||
|
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqTwoColumn;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className={cls("flex flex-col gap-5 md:gap-12 p-5 md:p-12 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-3">
|
||||||
|
<span className="flex items-center justify-center size-8 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-4xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-tight text-balance">{item.description}</p>
|
||||||
|
{(item.primaryButton || item.secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2">
|
||||||
|
{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>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesAlternatingSplit;
|
||||||
90
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
90
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { ArrowRight } 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";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-5 h-full cursor-pointer group">
|
||||||
|
<div className="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>
|
||||||
|
<div className="flex flex-col justify-between gap-5 p-5 flex-1 card rounded">
|
||||||
|
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<div className="flex items-center justify-between gap-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{item.tags.map((itemTag) => (
|
||||||
|
<span key={itemTag} className="px-3 py-1 text-sm card rounded">{itemTag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="shrink-0 h-[1em] w-auto transition-transform duration-300 group-hover:-rotate-45" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesArrowCards;
|
||||||
104
src/components/sections/features/FeaturesBento.tsx
Normal file
104
src/components/sections/features/FeaturesBento.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
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"; items: { icon: IconInput; label: string; value: string }[] }
|
||||||
|
| { bentoComponent: "tilted-stack-cards"; items: [{ 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; items: IconInput[] }
|
||||||
|
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; texts: string[] }
|
||||||
|
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
|
||||||
|
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; items: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
||||||
|
| { bentoComponent: "media-stack"; items: [{ 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.items} />;
|
||||||
|
case "tilted-stack-cards": return <TiltedStackCards items={feature.items} />;
|
||||||
|
case "animated-bar-chart": return <AnimatedBarChart />;
|
||||||
|
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.items} />;
|
||||||
|
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.texts} />;
|
||||||
|
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.items} completedLabel={feature.completedLabel} />;
|
||||||
|
case "media-stack": return <MediaStack items={feature.items} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div key={feature.title} className="flex flex-col gap-5 p-5 card rounded h-full">
|
||||||
|
<div className="relative h-72 overflow-hidden">{getBentoComponent(feature)}</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
|
||||||
|
<p className="text-sm leading-tight">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FeaturesBento;
|
||||||
83
src/components/sections/features/FeaturesComparison.tsx
Normal file
83
src/components/sections/features/FeaturesComparison.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
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-5 p-5 card rounded opacity-50">
|
||||||
|
{negativeItems.map((item) => (
|
||||||
|
<div key={item} className="flex items-center gap-2 text-base">
|
||||||
|
<X className="shrink-0 h-[1em] w-auto" />
|
||||||
|
<span className="text-base truncate">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 p-5 card rounded">
|
||||||
|
{positiveItems.map((item) => (
|
||||||
|
<div key={item} className="flex items-center gap-2 text-base">
|
||||||
|
<Check className="shrink-0 h-[1em] w-auto" />
|
||||||
|
<span className="text-base truncate">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</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 { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<motion.article
|
||||||
|
key={item.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex flex-col md:grid md:grid-cols-10 2xl:w-8/10 mx-auto gap-5 md:gap-10 p-5 md:p-10 cursor-pointer card rounded group"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:col-span-6 gap-3 md:gap-12">
|
||||||
|
<h3 className="text-3xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col mt-auto gap-5">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.tags.map((itemTag) => (
|
||||||
|
<span key={itemTag} className="px-3 py-1 text-sm card rounded">{itemTag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-base md:text-2xl leading-tight text-balance">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-square md:col-span-4 rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesDetailedCards;
|
||||||
97
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
97
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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 (
|
||||||
|
<motion.div
|
||||||
|
key={step.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-5 md:gap-12 p-5 md:p-12 card rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-between w-full md:w-1/2">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<span className="w-fit px-3 py-1 text-sm card rounded">{step.tag}</span>
|
||||||
|
<h3 className="text-5xl md:text-8xl font-medium leading-none">{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 md:gap-5">
|
||||||
|
<h4 className="text-2xl md:text-3xl font-medium text-balance">{step.subtitle}</h4>
|
||||||
|
<p className="text-base md:text-lg leading-tight 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-8xl font-medium 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>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesDetailedSteps;
|
||||||
100
src/components/sections/features/FeaturesDualMedia.tsx
Normal file
100
src/components/sections/features/FeaturesDualMedia.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
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 { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
mediaItems: [
|
||||||
|
({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }),
|
||||||
|
({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesDualMediaProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesDualMedia = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesDualMediaProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={2}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const IconComponent = resolveIcon(item.icon);
|
||||||
|
return (
|
||||||
|
<div key={item.title} className="flex flex-col gap-5 p-5 h-full card rounded">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-center mb-1 size-15 primary-button rounded">
|
||||||
|
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<p className="text-base leading-tight">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 flex-1 mt-auto gap-5">
|
||||||
|
{item.mediaItems.map((mediaItem, mediaIndex) => (
|
||||||
|
<div key={mediaIndex} className="aspect-square rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={mediaItem.imageSrc} videoSrc={mediaItem.videoSrc} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesDualMedia;
|
||||||
118
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
118
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "motion/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";
|
||||||
|
|
||||||
|
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-5 p-5 card rounded backface-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-5">
|
||||||
|
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
|
||||||
|
<Plus className="h-2/5 w-2/5 text-primary-cta-text" />
|
||||||
|
</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 justify-between gap-5 p-5 card rounded backface-hidden transform-[rotateY(180deg)]">
|
||||||
|
<div className="flex items-start justify-between gap-5">
|
||||||
|
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
|
||||||
|
<Plus className="h-2/5 w-2/5 rotate-45 text-primary-cta-text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{item.descriptions.map((desc, index) => (
|
||||||
|
<p key={index} className="text-lg leading-tight text-foreground/75">{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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<FeatureFlipCard key={item.title} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesFlipCards;
|
||||||
90
src/components/sections/features/FeaturesIconCards.tsx
Normal file
90
src/components/sections/features/FeaturesIconCards.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
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 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="py-20">
|
||||||
|
<div className="flex flex-col w-content-width mx-auto gap-8">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{features.map((feature) => {
|
||||||
|
const FeatureIcon = resolveIcon(feature.icon);
|
||||||
|
return (
|
||||||
|
<div key={feature.title} className="flex flex-col gap-5 p-5 card rounded">
|
||||||
|
<HoverPattern className="flex items-center justify-center aspect-square rounded">
|
||||||
|
<div className="relative z-10 flex items-center justify-center size-12 primary-button rounded shadow">
|
||||||
|
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</HoverPattern>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
|
||||||
|
<p className="text-sm leading-tight">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesIconCards;
|
||||||
100
src/components/sections/features/FeaturesLabeledList.tsx
Normal file
100
src/components/sections/features/FeaturesLabeledList.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
bullets: string[];
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesLabeledListProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesLabeledList = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesLabeledListProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||||
|
{items.map((item) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.label}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex flex-col md:flex-row gap-5 md:gap-12 p-5 md:p-12 card rounded"
|
||||||
|
>
|
||||||
|
<div className="w-full md:w-1/2 flex md:justify-start">
|
||||||
|
<h3 className="text-7xl font-medium leading-tight truncate">{item.label}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-foreground/20 md:hidden" />
|
||||||
|
|
||||||
|
<div className="flex flex-col w-full md:w-1/2 gap-5">
|
||||||
|
<h4 className="text-2xl md:text-3xl font-medium leading-tight">{item.title}</h4>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{item.bullets.map((text, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span className="text-base">{text}</span>
|
||||||
|
{index < item.bullets.length - 1 && <span className="text-base text-accent">•</span>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2">
|
||||||
|
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />
|
||||||
|
<Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesLabeledList;
|
||||||
82
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
82
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { motion } from "motion/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";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-5 p-5 h-full card rounded">
|
||||||
|
<div className="aspect-square rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<p className="text-sm leading-tight">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesMediaCards;
|
||||||
99
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
99
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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 bottom-0 left-0 w-full h-1/3 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-linear-to-t from-foreground/60 to-transparent" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-5 p-5">
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<h3 className="text-2xl md:text-3xl font-medium leading-tight text-background">{item.title}</h3>
|
||||||
|
<p className="text-sm md:text-base leading-tight text-background/75">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
type="button"
|
||||||
|
aria-label={item.buttonHref ? `Navigate to ${item.buttonHref}` : "Action button"}
|
||||||
|
className="flex items-center justify-center shrink-0 size-8 cursor-pointer primary-button rounded"
|
||||||
|
>
|
||||||
|
<Icon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoopCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<FeatureMediaCarouselCard key={item.title} item={item} />
|
||||||
|
))}
|
||||||
|
</LoopCarousel>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesMediaCarousel;
|
||||||
104
src/components/sections/features/FeaturesProfileCards.tsx
Normal file
104
src/components/sections/features/FeaturesProfileCards.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { BadgeCheck } 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";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
avatarSrc: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonHref?: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const FeaturesProfileCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}) => (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="group relative overflow-hidden aspect-5/6 rounded">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||||
|
|
||||||
|
<div className="absolute top-5 right-5 z-20">
|
||||||
|
<Button text={item.buttonText} href={item.buttonHref} variant="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
|
||||||
|
<div className="relative flex flex-col gap-1 p-3">
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-8 shrink-0 overflow-hidden rounded secondary-button">
|
||||||
|
<img src={item.avatarSrc} alt="" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold leading-tight truncate text-background transition-colors duration-400 group-hover:text-foreground">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<BadgeCheck className="size-5 shrink-0 text-background transition-colors duration-400 group-hover:text-foreground" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||||
|
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FeaturesProfileCards;
|
||||||
105
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
105
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<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-5 left-5 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 rounded bg-background backface-hidden">
|
||||||
|
<span className="text-sm font-medium text-foreground">{index + 1}</span>
|
||||||
|
</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-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
|
||||||
|
<div className="relative flex flex-col gap-1 p-3">
|
||||||
|
<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-tight text-background 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-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesRevealCards;
|
||||||
90
src/components/sections/features/FeaturesStatisticsCards.tsx
Normal file
90
src/components/sections/features/FeaturesStatisticsCards.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesStatisticsCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesStatisticsCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesStatisticsCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col h-full card rounded">
|
||||||
|
<div className="flex flex-col flex-1 gap-10 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl md:text-3xl font-medium leading-tight truncate">{item.title}</h3>
|
||||||
|
<p className="text-base md:text-lg leading-tight text-foreground/75">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 mt-auto">
|
||||||
|
<div className="flex items-center min-w-0 flex-1 gap-2">
|
||||||
|
<span className="shrink-0 size-[1em] rounded-sm bg-accent" />
|
||||||
|
<span className="text-base truncate">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl md:text-2xl font-medium">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesStatisticsCards;
|
||||||
92
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
92
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { motion } from "motion/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";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { 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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-5 h-full 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" />
|
||||||
|
<span className="absolute top-5 right-5 px-3 py-1 text-sm card rounded">{item.tag}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5 p-5 flex-1 card rounded">
|
||||||
|
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
|
||||||
|
<p className="text-base leading-tight">{item.description}</p>
|
||||||
|
{(item.primaryButton || item.secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesTaggedCards;
|
||||||
123
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
123
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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="full" className="absolute inset-6 overflow-hidden rounded">
|
||||||
|
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cls(
|
||||||
|
"grid grid-cols-1 gap-5",
|
||||||
|
items.length === 2 && "md:grid-cols-2",
|
||||||
|
items.length === 3 && "md:grid-cols-3",
|
||||||
|
items.length >= 4 && "md:grid-cols-4"
|
||||||
|
)}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
data-active={index === activeIndex}
|
||||||
|
onClick={() => handleCardClick(index)}
|
||||||
|
className="flex flex-col justify-between gap-5 p-5 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;
|
||||||
61
src/components/sections/footer/FooterBasic.tsx
Normal file
61
src/components/sections/footer/FooterBasic.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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">
|
||||||
|
<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">{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-medium 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-medium">{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-medium 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-medium">{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-medium" 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-medium">{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">{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-medium">{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">{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-medium">{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">{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-medium">{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">{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;
|
||||||
62
src/components/sections/hero/HeroBillboard.tsx
Normal file
62
src/components/sections/hero/HeroBillboard.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type HeroBillboardProps = {
|
||||||
|
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 HeroBillboard = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: HeroBillboardProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="pt-25 pb-20 md:py-30">
|
||||||
|
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="w-full p-3 md:p-5 card rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboard;
|
||||||
54
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
54
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import AutoFillText from "@/components/ui/AutoFillText";
|
||||||
|
|
||||||
|
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="pt-25 pb-20 md:py-30">
|
||||||
|
<div className="flex flex-col gap-10 md:gap-13 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"
|
||||||
|
tag="p"
|
||||||
|
className="w-full md:w-1/2 text-lg md:text-2xl leading-tight 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" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="w-full p-3 md:p-5 card rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardBrand;
|
||||||
69
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
69
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
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="flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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-1.5 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;
|
||||||
76
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
76
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useScroll, useTransform, motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="pt-25 pb-20 md:py-30 perspective-distant"
|
||||||
|
>
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto mt-8 p-3 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-5 card rounded overflow-hidden hidden md:block"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardScroll;
|
||||||
124
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
124
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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 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 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="pt-25 pb-20 md:py-30">
|
||||||
|
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="relative w-full p-3 md:p-5 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-6 left-6 right-6 md:left-10 md:bottom-10 md:right-auto md:max-w-sm p-5 card rounded flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{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-tight text-balance">{testimonial.text}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={testimonial.imageSrc}
|
||||||
|
videoSrc={testimonial.videoSrc}
|
||||||
|
className="size-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{testimonial.name}</span>
|
||||||
|
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardTestimonial;
|
||||||
55
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
55
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
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="flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TiltedCarousel items={items} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardTiltedCarousel;
|
||||||
62
src/components/sections/hero/HeroBrand.tsx
Normal file
62
src/components/sections/hero/HeroBrand.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
tag="p"
|
||||||
|
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-medium leading-tight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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" animateImmediately />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AutoFillText className="font-semibold">{brand}</AutoFillText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBrand;
|
||||||
106
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
106
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
tag="p"
|
||||||
|
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-medium leading-tight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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" animateImmediately />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AutoFillText className="font-semibold">{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-foreground/20 cursor-pointer"
|
||||||
|
onClick={() => setCurrentIndex(index)}
|
||||||
|
aria-label="Slide"
|
||||||
|
aria-current={currentIndex === index}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"absolute inset-0 bg-foreground 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;
|
||||||
67
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
67
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
|
||||||
|
type HeroCenteredLogosProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
logos: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeroCenteredLogos = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
logos,
|
||||||
|
}: HeroCenteredLogosProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="h-svh flex flex-col">
|
||||||
|
<div className="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">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="md:max-w-7/10 text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="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 rounded card">
|
||||||
|
<span className="text-xl font-semibold whitespace-nowrap opacity-75">{logo}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroCenteredLogos;
|
||||||
66
src/components/sections/hero/HeroOverlay.tsx
Normal file
66
src/components/sections/hero/HeroOverlay.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type HeroOverlayProps = {
|
||||||
|
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 HeroOverlay = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: HeroOverlayProps) => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Hero section"
|
||||||
|
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
|
||||||
|
>
|
||||||
|
<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-4/10 2xl:w-35/100">
|
||||||
|
<span className="w-fit px-3 py-1 mb-1.5 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 mt-1.5">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroOverlay;
|
||||||
129
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
129
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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 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"
|
||||||
|
>
|
||||||
|
<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-4/10 2xl:w-35/100">
|
||||||
|
<span className="w-fit px-3 py-1 mb-1.5 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 mt-1.5">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={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-3 left-3 right-3 p-5 card rounded flex flex-col gap-5 md:left-auto md:bottom-8 md:right-8 md:max-w-25/100 2xl:max-w-2/10"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{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-tight text-balance">{testimonial.text}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={testimonial.imageSrc}
|
||||||
|
videoSrc={testimonial.videoSrc}
|
||||||
|
className="size-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{testimonial.name}</span>
|
||||||
|
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroOverlayTestimonial;
|
||||||
64
src/components/sections/hero/HeroSplit.tsx
Normal file
64
src/components/sections/hero/HeroSplit.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-10 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">
|
||||||
|
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
tag="h1"
|
||||||
|
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
tag="p"
|
||||||
|
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-3 card rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSplit;
|
||||||
129
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
129
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-10 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">
|
||||||
|
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
tag="h1"
|
||||||
|
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
tag="p"
|
||||||
|
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="w-full h-full p-3 card rounded overflow-hidden scale-80"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{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 md:p-5 card backdrop-blur-sm rounded",
|
||||||
|
KPI_POSITIONS[index]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-2xl md:text-4xl font-medium">{kpi.value}</p>
|
||||||
|
<p className="text-sm md:text-base text-foreground/70">{kpi.label}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSplitKpi;
|
||||||
71
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
71
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-10 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">
|
||||||
|
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
tag="h1"
|
||||||
|
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
tag="p"
|
||||||
|
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
|
||||||
|
className="w-full md:w-1/2 grid grid-cols-2 gap-3"
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={index} className="h-80 md:h-[55vh] p-3 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSplitMediaGrid;
|
||||||
126
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
126
src/components/sections/hero/HeroSplitTestimonial.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 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 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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-10 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">
|
||||||
|
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
tag="h1"
|
||||||
|
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
tag="p"
|
||||||
|
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", 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-3 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-6 left-6 right-6 md:left-auto md:max-w-5/10 p-5 card rounded flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{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-tight text-balance">{testimonial.text}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={testimonial.imageSrc}
|
||||||
|
videoSrc={testimonial.videoSrc}
|
||||||
|
className="size-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{testimonial.name}</span>
|
||||||
|
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSplitTestimonial;
|
||||||
81
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
81
src/components/sections/hero/HeroSplitVerticalMarquee.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";
|
||||||
|
|
||||||
|
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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-10 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">
|
||||||
|
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
tag="h1"
|
||||||
|
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
tag="p"
|
||||||
|
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-3 overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||||
|
<div className="flex flex-col gap-3 animate-marquee-vertical">
|
||||||
|
{duplicatedLeft.map((item, index) => (
|
||||||
|
<div key={index} className="shrink-0 aspect-square p-3 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||||
|
<div className="flex flex-col gap-3 animate-marquee-vertical-reverse">
|
||||||
|
{duplicatedRight.map((item, index) => (
|
||||||
|
<div key={index} className="shrink-0 aspect-square p-3 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSplitVerticalMarquee;
|
||||||
99
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
99
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
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="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||||
|
<div className="flex flex-col items-center gap-10 md:gap-15 w-full md:w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-2 w-content-width mx-auto text-center">
|
||||||
|
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h1"
|
||||||
|
className="text-6xl font-medium text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="text-base md:text-lg leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", 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 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut", 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 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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroTiltedCards;
|
||||||
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-medium leading-tight">{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-medium leading-tight">{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;
|
||||||
87
src/components/sections/metrics/MetricsFeatureCards.tsx
Normal file
87
src/components/sections/metrics/MetricsFeatureCards.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 h-full card rounded">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<span className="text-8xl md:text-7xl font-medium 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-accent">
|
||||||
|
{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-tight">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsFeatureCards;
|
||||||
93
src/components/sections/metrics/MetricsGradientCards.tsx
Normal file
93
src/components/sections/metrics/MetricsGradientCards.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
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 { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type Metric = {
|
||||||
|
value: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricsGradientCards = ({
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const IconComponent = resolveIcon(metric.icon);
|
||||||
|
return (
|
||||||
|
<div key={metric.value} className="relative flex flex-col items-center justify-center gap-0 p-5 min-h-70 h-full card rounded">
|
||||||
|
<span
|
||||||
|
className="text-9xl font-medium leading-none text-center truncate"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "linear-gradient(to bottom, var(--color-foreground) 0%, var(--color-foreground) 20%, transparent 72%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metric.value}
|
||||||
|
</span>
|
||||||
|
<span className="mt-[-0.75em] text-4xl font-medium text-center truncate">{metric.title}</span>
|
||||||
|
<p className="max-w-9/10 md:max-w-7/10 mt-2 text-base leading-tight text-center line-clamp-2">{metric.description}</p>
|
||||||
|
<div className="absolute bottom-5 left-5 flex items-center justify-center size-10 primary-button rounded">
|
||||||
|
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsGradientCards;
|
||||||
83
src/components/sections/metrics/MetricsIconCards.tsx
Normal file
83
src/components/sections/metrics/MetricsIconCards.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
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 { 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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const IconComponent = resolveIcon(metric.icon);
|
||||||
|
return (
|
||||||
|
<div key={metric.value} className="flex flex-col items-center justify-center gap-3 p-5 min-h-70 h-full card rounded">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="flex items-center justify-center 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">{metric.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-7xl font-medium leading-none truncate">{metric.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsIconCards;
|
||||||
99
src/components/sections/metrics/MetricsMediaCards.tsx
Normal file
99
src/components/sections/metrics/MetricsMediaCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={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 (
|
||||||
|
<motion.div
|
||||||
|
key={metric.value}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className={cls("grid grid-cols-2 gap-5", shouldSpanFull && "md:col-span-2")}
|
||||||
|
>
|
||||||
|
<div className={cls(
|
||||||
|
"flex flex-col justify-between gap-5 p-5 card rounded",
|
||||||
|
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
|
||||||
|
isEven && "order-2 md:order-1"
|
||||||
|
)}>
|
||||||
|
<span className="text-5xl md:text-6xl font-medium leading-tight truncate">{metric.value}</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl md:text-2xl font-medium truncate">{metric.title}</span>
|
||||||
|
<div className="w-full h-px bg-accent" />
|
||||||
|
<p className="text-base leading-tight 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>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsMediaCards;
|
||||||
62
src/components/sections/metrics/MetricsMinimalCards.tsx
Normal file
62
src/components/sections/metrics/MetricsMinimalCards.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
|
||||||
|
type Metric = {
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricsMinimalCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
metrics,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
metrics: Metric[];
|
||||||
|
}) => (
|
||||||
|
<section aria-label="Metrics section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<span className="px-3 py-1 w-fit text-sm card rounded md:hidden">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-3xl md:text-5xl font-medium leading-tight text-balance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-accent" />
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start gap-8">
|
||||||
|
<span className="hidden md:block px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className={cls(
|
||||||
|
"grid grid-cols-1 gap-5 flex-1",
|
||||||
|
metrics.length >= 2 && "md:grid-cols-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 md:p-8 aspect-video card rounded">
|
||||||
|
<span className="text-6xl md:text-8xl font-medium leading-none truncate">{metric.value}</span>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="w-full h-px bg-accent" />
|
||||||
|
<p className="text-base md:text-lg leading-tight text-balance">{metric.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsMinimalCards;
|
||||||
72
src/components/sections/metrics/MetricsSimpleCards.tsx
Normal file
72
src/components/sections/metrics/MetricsSimpleCards.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 GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel carouselThreshold={3}>
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 min-h-70 h-full card rounded">
|
||||||
|
<span className="text-7xl md:text-8xl font-medium leading-none truncate">{metric.value}</span>
|
||||||
|
<p className="text-base leading-tight text-balance">{metric.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricsSimpleCards;
|
||||||
99
src/components/sections/pricing/PricingCenteredCards.tsx
Normal file
99
src/components/sections/pricing/PricingCenteredCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<div key={plan.tag} className="flex flex-col items-center gap-5 p-5 h-full card rounded text-center">
|
||||||
|
<span className="px-5 py-2 text-sm card rounded">{plan.tag}</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-5xl font-medium">{plan.price}</span>
|
||||||
|
<span className="text-base">{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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PricingCenteredCards;
|
||||||
105
src/components/sections/pricing/PricingHighlightedCards.tsx
Normal file
105
src/components/sections/pricing/PricingHighlightedCards.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<div key={plan.tag} className="flex flex-col h-full">
|
||||||
|
<div className={cls("px-5 py-2 text-sm", 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-5 p-5 flex-1 card text-center", plan.highlight ? "rounded-t-none rounded-b" : "rounded")}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-5xl font-medium">{plan.price}</span>
|
||||||
|
<span className="text-xl 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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PricingHighlightedCards;
|
||||||
99
src/components/sections/pricing/PricingLayeredCards.tsx
Normal file
99
src/components/sections/pricing/PricingLayeredCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<GridOrCarousel>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<div key={plan.tag} className="flex flex-col gap-3 p-3 h-full card rounded">
|
||||||
|
<div className="flex flex-col gap-3 p-5 secondary-button rounded">
|
||||||
|
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.tag}</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-5xl font-medium">{plan.price}</span>
|
||||||
|
<span className="text-base">{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">
|
||||||
|
{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-tight">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PricingLayeredCards;
|
||||||
95
src/components/sections/pricing/PricingMediaCards.tsx
Normal file
95
src/components/sections/pricing/PricingMediaCards.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
|
||||||
|
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<motion.div
|
||||||
|
key={plan.tag}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
className="flex flex-col md:flex-row gap-5 md:gap-10 p-5 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-5 w-full md:w-1/2">
|
||||||
|
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.price}{plan.period}</span>
|
||||||
|
<h3 className="text-4xl md:text-5xl font-medium truncate">{plan.tag}</h3>
|
||||||
|
|
||||||
|
<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-sm leading-tight">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-fit mt-1" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PricingMediaCards;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user