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=c78450f7-6750-4b73-93f0-901e35aaf497
|
||||||
|
|
||||||
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?
|
||||||
206
CARD_PATTERN_CHANGES.md
Normal file
206
CARD_PATTERN_CHANGES.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Card Pattern Consistency Update
|
||||||
|
|
||||||
|
This document outlines the card padding, gap, and font-weight consistency changes made across all section components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes Summary
|
||||||
|
|
||||||
|
### 1. Container Gap Changes
|
||||||
|
```
|
||||||
|
OLD: gap-3 xl:gap-4 2xl:gap-5
|
||||||
|
NEW: gap-4 xl:gap-5 2xl:gap-6
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Container Padding Changes
|
||||||
|
```
|
||||||
|
OLD: p-3 xl:p-4 2xl:p-5
|
||||||
|
NEW: p-6 xl:p-7 2xl:p-8
|
||||||
|
|
||||||
|
OLD: p-4 xl:p-5 2xl:p-6
|
||||||
|
NEW: p-6 xl:p-7 2xl:p-8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Font Weight Changes
|
||||||
|
```
|
||||||
|
OLD: font-medium
|
||||||
|
NEW: font-semibold
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Margin Changes
|
||||||
|
```
|
||||||
|
OLD: mb-3 xl:mb-4 2xl:mb-5
|
||||||
|
NEW: mb-4 xl:mb-5 2xl:mb-6
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Horizontal Padding Changes
|
||||||
|
```
|
||||||
|
OLD: px-3 xl:px-4 2xl:px-5
|
||||||
|
NEW: px-6 xl:px-7 2xl:px-8
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Pattern
|
||||||
|
|
||||||
|
The canonical reference is `AboutFeaturesSplit.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
|
||||||
|
<div className="flex flex-col justify-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 w-full md:w-4/10 2xl:w-35/100 card rounded">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const ItemIcon = resolveIcon(item.icon);
|
||||||
|
return (
|
||||||
|
<div key={item.title}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
|
||||||
|
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
{index < items.length - 1 && (
|
||||||
|
<div className="mt-4 xl:mt-5 2xl:mt-6 border-b border-accent/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-px w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
|
||||||
|
<div className="relative size-full">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Updated
|
||||||
|
|
||||||
|
### Gap Pattern (32 files)
|
||||||
|
- `src/components/sections/metrics/MetricsMediaCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductVariantCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductRatingCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductQuantityCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductMediaCards.tsx`
|
||||||
|
- `src/components/sections/team/TeamOverlayCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialQuoteCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialRatingCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialColumnMarqueeCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesTimelineCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesTaggedCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesMediaGrid.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesMediaCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesGridSplit.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesFlipCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesComparison.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesBorderGlow.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesBentoGrid.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesBento.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesArrowCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesIconCards.tsx`
|
||||||
|
- `src/components/sections/blog/BlogTagCards.tsx`
|
||||||
|
- `src/components/sections/blog/BlogSimpleCards.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsFeatureCards.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsSimpleCards.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingHighlightedCards.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingSimpleCards.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingCenteredCards.tsx`
|
||||||
|
- `src/components/sections/hero/HeroWorkScrollStack.tsx`
|
||||||
|
- `src/components/sections/hero/HeroBillboardTestimonial.tsx`
|
||||||
|
- `src/components/sections/hero/HeroOverlayTestimonial.tsx`
|
||||||
|
- `src/components/sections/hero/HeroSplitTestimonial.tsx`
|
||||||
|
|
||||||
|
### Padding Pattern (44 files)
|
||||||
|
- `src/components/sections/about/AboutTestimonialParallax.tsx`
|
||||||
|
- `src/components/sections/about/AboutFeaturesSplit.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsMediaCards.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsIconCards.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsFeatureCards.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsGradientCards.tsx`
|
||||||
|
- `src/components/sections/metrics/MetricsSimpleCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductVariantCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductRatingCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductQuantityCards.tsx`
|
||||||
|
- `src/components/sections/product/ProductMediaCards.tsx`
|
||||||
|
- `src/components/sections/faq/FaqTwoColumn.tsx`
|
||||||
|
- `src/components/sections/team/TeamOverlayCards.tsx`
|
||||||
|
- `src/components/sections/team/TeamListCards.tsx`
|
||||||
|
- `src/components/sections/team/TeamDetailedCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialQuoteCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialOverlayCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialRatingCards.tsx`
|
||||||
|
- `src/components/sections/testimonial/TestimonialColumnMarqueeCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesTimelineCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesTaggedCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesRevealCardsBento.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesResultsComparison.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesMediaCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesFlipCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesComparison.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesBorderGlow.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesBentoGrid.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesBento.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesArrowCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesIconCards.tsx`
|
||||||
|
- `src/components/sections/features/FeaturesGridSplit.tsx`
|
||||||
|
- `src/components/sections/blog/BlogTagCards.tsx`
|
||||||
|
- `src/components/sections/blog/BlogSimpleCards.tsx`
|
||||||
|
- `src/components/sections/blog/BlogArticle.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingMediaCards.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingHighlightedCards.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingSimpleCards.tsx`
|
||||||
|
- `src/components/sections/pricing/PricingCenteredCards.tsx`
|
||||||
|
- `src/components/sections/hero/HeroBillboardTestimonial.tsx`
|
||||||
|
- `src/components/sections/hero/HeroOverlayTestimonial.tsx`
|
||||||
|
- `src/components/sections/hero/HeroSplitTestimonial.tsx`
|
||||||
|
- `src/components/sections/hero/HeroSplitKpi.tsx`
|
||||||
|
- `src/components/sections/contact/ContactSplitFormParallax.tsx`
|
||||||
|
|
||||||
|
### Font Weight (106+ files)
|
||||||
|
All section components with `font-medium` class were updated to `font-semibold`.
|
||||||
|
|
||||||
|
### UI Components Updated (Phase 2)
|
||||||
|
- `src/components/ui/Card.tsx` - padding
|
||||||
|
- `src/components/ui/Modal.tsx` - padding, gap, margin, font-weight
|
||||||
|
- `src/components/ui/Sheet.tsx` - padding, gap, margin, font-weight
|
||||||
|
- `src/components/ui/NavbarFloating.tsx` - padding, gap, horizontal padding, font-weight
|
||||||
|
- `src/components/ui/NavbarFloatingLogo.tsx` - padding, gap, horizontal padding, font-weight
|
||||||
|
- `src/components/ui/Accordion.tsx` - font-weight
|
||||||
|
- `src/components/ui/ActiveBadge.tsx` - font-weight
|
||||||
|
- `src/components/ui/AvatarGroup.tsx` - font-weight
|
||||||
|
- `src/components/ui/Calendar.tsx` - font-weight
|
||||||
|
- `src/components/ui/DropdownMenu.tsx` - font-weight
|
||||||
|
- `src/components/ui/Label.tsx` - font-weight
|
||||||
|
- `src/components/ui/LoaderReveal.tsx` - font-weight
|
||||||
|
- `src/components/ui/NavbarCentered.tsx` - font-weight
|
||||||
|
- `src/components/ui/NavbarDropdown.tsx` - font-weight
|
||||||
|
- `src/components/ui/NavbarFullscreen.tsx` - font-weight
|
||||||
|
- `src/components/ui/NavbarInline.tsx` - font-weight
|
||||||
|
- `src/components/ui/SectionErrorBoundary.tsx` - font-weight
|
||||||
|
- `src/components/ui/SelectorButton.tsx` - font-weight
|
||||||
|
|
||||||
|
### E-commerce Components Updated (Phase 2)
|
||||||
|
- `src/components/ecommerce/ProductCart.tsx` - font-weight
|
||||||
|
- `src/components/ecommerce/ProductCatalog.tsx` - font-weight
|
||||||
|
- `src/components/ecommerce/ProductDetailCard.tsx` - font-weight
|
||||||
|
|
||||||
|
### Pages Updated (Phase 2)
|
||||||
|
All 72 preview/demo pages in `src/pages/` were updated with `font-semibold`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Impact
|
||||||
|
|
||||||
|
These changes result in:
|
||||||
|
1. **Larger card padding** - More breathing room inside cards
|
||||||
|
2. **Larger gaps** - More space between card elements
|
||||||
|
3. **Bolder titles** - Semibold instead of medium weight for better hierarchy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run `npm run build` to verify all changes compile correctly.
|
||||||
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.
|
||||||
|
|
||||||
|
**Use `cls()` for dynamic classes.** When state-based styling is needed (e.g., `menuOpen ? "rotate-45" : "rotate-0"`), use the `cls()` utility from `@/lib/utils`.
|
||||||
|
|
||||||
|
**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 |
|
||||||
|
|
||||||
|
---
|
||||||
452
TEMPLATES.md
Normal file
452
TEMPLATES.md
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# Templates
|
||||||
|
|
||||||
|
Templates are complete website starting points with customizable theming, pre-built sections, and consistent patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── templates/ # Template Pages (full websites)
|
||||||
|
│ ├── hotel/
|
||||||
|
│ │ ├── page.tsx # Page component with sections
|
||||||
|
│ │ └── theme.css # Colors, buttons, cards
|
||||||
|
│ ├── saas/
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── components/
|
||||||
|
└── templates/ # Template Components (reusable sections)
|
||||||
|
├── ResultsComparison.tsx
|
||||||
|
└── HeroOverlayMarquee.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Pages
|
||||||
|
|
||||||
|
Located in `/src/templates/[name]/`, each template contains:
|
||||||
|
- `page.tsx` - Full page with StyleProvider wrapper and sections
|
||||||
|
- `theme.css` - CSS variables for colors, typography, buttons, cards
|
||||||
|
|
||||||
|
### page.tsx Structure
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StyleProvider } from "@/components/ui/StyleProvider";
|
||||||
|
import NavbarInline from "@/components/ui/NavbarInline";
|
||||||
|
import HeroBillboard from "@/components/sections/hero/HeroBillboard";
|
||||||
|
import FooterSimple from "@/components/sections/footer/FooterSimple";
|
||||||
|
import "./theme.css";
|
||||||
|
|
||||||
|
export default function TemplateName() {
|
||||||
|
return (
|
||||||
|
<StyleProvider
|
||||||
|
siteBackground="aurora"
|
||||||
|
heroBackground="cornerGlow"
|
||||||
|
buttonVariant="stagger"
|
||||||
|
>
|
||||||
|
<NavbarInline {...navProps} />
|
||||||
|
|
||||||
|
<HeroBillboard {...heroProps} />
|
||||||
|
|
||||||
|
{/* More sections... */}
|
||||||
|
|
||||||
|
<FooterSimple {...footerProps} />
|
||||||
|
</StyleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StyleProvider Options
|
||||||
|
|
||||||
|
| Prop | Options |
|
||||||
|
|------|---------|
|
||||||
|
| `siteBackground` | `"aurora"`, `"cornerGlow"`, `"lightRaysCenter"`, `"lightRaysCorner"`, `"floatingGradient"`, `"gradientBars"`, `"horizonGlow"`, `"none"` |
|
||||||
|
| `heroBackground` | `"cornerGlow"`, `"lightRaysCenter"`, `"lightRaysCorner"`, `"horizonGlow"`, `"gradientBars"`, `"none"` |
|
||||||
|
| `buttonVariant` | `"stagger"`, `"arrow"`, `"expand"`, `"elastic"`, `"shift"`, `"magnetic"`, `"default"` |
|
||||||
|
|
||||||
|
### Asset Handling
|
||||||
|
|
||||||
|
Use Google Cloud Storage CDN for all media:
|
||||||
|
```
|
||||||
|
https://storage.googleapis.com/webild/default/templates/[template-name]/[asset].webp
|
||||||
|
https://storage.googleapis.com/webild/default/templates/[template-name]/[asset].mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Sections to a Template
|
||||||
|
|
||||||
|
### Decision Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Need a section for your template
|
||||||
|
↓
|
||||||
|
2. Check /src/components/sections/[category]/
|
||||||
|
↓
|
||||||
|
┌───────┴───────┐
|
||||||
|
↓ ↓
|
||||||
|
EXISTS? DOESN'T EXIST?
|
||||||
|
↓ ↓
|
||||||
|
Import & Create template component
|
||||||
|
use it in /src/components/templates/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1: Check Existing Sections
|
||||||
|
|
||||||
|
Look in `/src/components/sections/` by category:
|
||||||
|
|
||||||
|
| Category | Path | Examples |
|
||||||
|
|----------|------|----------|
|
||||||
|
| Hero | `hero/` | HeroBillboard, HeroSplit, HeroOverlay |
|
||||||
|
| Features | `features/` | FeaturesBento, FeaturesMediaCards |
|
||||||
|
| Testimonial | `testimonial/` | TestimonialTrustCard, TestimonialRatingCards |
|
||||||
|
| Pricing | `pricing/` | PricingCards, PricingComparison |
|
||||||
|
| Footer | `footer/` | FooterSimple, FooterSimpleCard |
|
||||||
|
| Contact | `contact/` | ContactSplitForm, ContactCenter |
|
||||||
|
| FAQ | `faq/` | FaqSimple, FaqTwoColumn |
|
||||||
|
| Team | `team/` | TeamCards, TeamGrid |
|
||||||
|
| Blog | `blog/` | BlogSimpleCards |
|
||||||
|
| About | `about/` | AboutTextSplit |
|
||||||
|
| Metrics | `metrics/` | MetricsCards |
|
||||||
|
|
||||||
|
### Step 2a: If Section Exists → Use It
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import HeroBillboard from "@/components/sections/hero/HeroBillboard";
|
||||||
|
import FeaturesMediaCards from "@/components/sections/features/FeaturesMediaCards";
|
||||||
|
|
||||||
|
<HeroBillboard
|
||||||
|
tag="Welcome"
|
||||||
|
title="Your Title"
|
||||||
|
description="Description text"
|
||||||
|
primaryButton={{ text: "Get Started", href: "#contact" }}
|
||||||
|
imageSrc="https://..."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2b: If Section Doesn't Exist → Create Template Component
|
||||||
|
|
||||||
|
1. **First, study similar sections** in `/src/components/sections/[category]/`
|
||||||
|
- Read 2-3 sections in the same category
|
||||||
|
- Understand their props structure
|
||||||
|
- Note which UI components they use
|
||||||
|
- Observe animation and layout patterns
|
||||||
|
|
||||||
|
2. **Create your component** in `/src/components/templates/[Name].tsx`
|
||||||
|
- Follow the same patterns you observed
|
||||||
|
- Use consistent prop naming (tag, title, description, items)
|
||||||
|
- Import the same UI components (Button, TextAnimation, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Components
|
||||||
|
|
||||||
|
Located in `/src/components/templates/`, these are reusable section-level components for templates when no standard section fits.
|
||||||
|
|
||||||
|
### When to Create
|
||||||
|
|
||||||
|
Create when no suitable section exists in `/src/components/sections/` (see "Adding Sections" above).
|
||||||
|
|
||||||
|
### Current Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `ResultsComparison` | Before/after comparison marquee with treatment cards |
|
||||||
|
| `HeroOverlayMarquee` | Full-screen hero with media background and bottom marquee |
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
Template components follow the same patterns as UI components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ItemType = {
|
||||||
|
// item properties
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ComponentProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: ItemType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComponentName = ({ tag, title, description, primaryButton, secondaryButton, items }: ComponentProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Section label" className="py-20">
|
||||||
|
{/* Section content */}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComponentName;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## theme.css
|
||||||
|
|
||||||
|
Each template's theme.css defines the visual identity.
|
||||||
|
|
||||||
|
### Required Structure
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* [Template Name] - [Theme Description] */
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "../../styles/masks.css";
|
||||||
|
@import "../../styles/animations.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Colors (9 required) */
|
||||||
|
--background: #ffffff;
|
||||||
|
--card: #f5f5f5;
|
||||||
|
--foreground: #171717;
|
||||||
|
--primary-cta: #171717;
|
||||||
|
--primary-cta-text: #ffffff;
|
||||||
|
--secondary-cta: #f5f5f5;
|
||||||
|
--secondary-cta-text: #171717;
|
||||||
|
--accent: #171717;
|
||||||
|
--background-accent: #171717;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--radius: 1.5rem;
|
||||||
|
--width-content-width: clamp(40rem, 72.5vw, 100rem);
|
||||||
|
|
||||||
|
/* Carousel */
|
||||||
|
--vw-1_5: 1.35rem;
|
||||||
|
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - 1rem);
|
||||||
|
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
|
||||||
|
--width-carousel-item-2: calc(var(--width-content-width) / 2 - var(--vw-1_5) / 2);
|
||||||
|
--width-carousel-item-3: calc(var(--width-content-width) / 3 - var(--vw-1_5) / 3 * 2);
|
||||||
|
--width-carousel-item-4: calc(var(--width-content-width) / 4 - var(--vw-1_5) / 4 * 3);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--text-2xs: 0.62rem;
|
||||||
|
--text-xs: 0.72rem;
|
||||||
|
--text-sm: 0.82rem;
|
||||||
|
--text-base: 0.92rem;
|
||||||
|
--text-lg: 1rem;
|
||||||
|
--text-xl: 1.1rem;
|
||||||
|
--text-2xl: 1.3rem;
|
||||||
|
--text-3xl: 1.6rem;
|
||||||
|
--text-4xl: 2rem;
|
||||||
|
--text-5xl: 2.75rem;
|
||||||
|
--text-6xl: 3.3rem;
|
||||||
|
--text-7xl: 4rem;
|
||||||
|
--text-8xl: 4.5rem;
|
||||||
|
--text-9xl: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile typography */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--text-2xs: 2.5vw;
|
||||||
|
/* ... mobile sizes ... */
|
||||||
|
--width-content-width: 85vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
/* Tailwind color mappings */
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
* { scrollbar-width: thin; }
|
||||||
|
html { overscroll-behavior: none; }
|
||||||
|
body { background-color: var(--background); color: var(--foreground); }
|
||||||
|
|
||||||
|
/* WEBILD_CARD_STYLE */
|
||||||
|
.card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WEBILD_PRIMARY_BUTTON */
|
||||||
|
.primary-button {
|
||||||
|
background: linear-gradient(180deg, #1e1e1e 0%, #0c0c0c 100%);
|
||||||
|
box-shadow: /* complex shadow */;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WEBILD_SECONDARY_BUTTON */
|
||||||
|
.secondary-button {
|
||||||
|
background: var(--color-secondary-cta);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props & Naming Conventions
|
||||||
|
|
||||||
|
### Standard Props
|
||||||
|
|
||||||
|
| Prop | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| `tag` | Small badge/label text |
|
||||||
|
| `title` | Main heading (h1/h2) |
|
||||||
|
| `description` | Subtitle/description text |
|
||||||
|
| `primaryButton` | `{ text: string; href: string }` |
|
||||||
|
| `secondaryButton` | `{ text: string; href: string }` |
|
||||||
|
| `items` / `features` | Array of data items |
|
||||||
|
| `imageSrc` / `videoSrc` | Media sources (exclusive) |
|
||||||
|
| `className` | Main wrapper element |
|
||||||
|
| `[element]ClassName` | Specific element (titleClassName, etc.) |
|
||||||
|
|
||||||
|
### Discriminated Union for Media
|
||||||
|
|
||||||
|
When a component accepts either image OR video (not both):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Props = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
// ...other props
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### cls() Utility
|
||||||
|
|
||||||
|
Always use for conditional classNames:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
className={cls(
|
||||||
|
"base-classes px-4 py-2",
|
||||||
|
isActive && "bg-primary",
|
||||||
|
variant === "large" ? "text-xl" : "text-base"
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Padding
|
||||||
|
|
||||||
|
Use consistent responsive spacing:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Container padding
|
||||||
|
className="p-3 xl:p-4 2xl:p-5"
|
||||||
|
|
||||||
|
// Gaps
|
||||||
|
className="gap-3 xl:gap-4 2xl:gap-5"
|
||||||
|
|
||||||
|
// Margins
|
||||||
|
className="mb-3 xl:mb-4 2xl:mb-5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
|
||||||
|
Import from `/src/components/ui/`:
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `Button` | CTA buttons with variant prop |
|
||||||
|
| `TextAnimation` | Animated headings with gradientText option |
|
||||||
|
| `ImageOrVideo` | Handles both img and video display |
|
||||||
|
| `ScrollReveal` | Scroll-triggered animation wrapper |
|
||||||
|
| `AvatarGroup` | Avatar displays with overflow and label |
|
||||||
|
| `Accordion` | Expandable content sections |
|
||||||
|
|
||||||
|
### Marquee Pattern
|
||||||
|
|
||||||
|
For infinite horizontal scrolling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const duplicated = [...items, ...items, ...items, ...items];
|
||||||
|
|
||||||
|
<div className="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 mr-3 md:mr-5">
|
||||||
|
{/* Item content */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Section Header Pattern
|
||||||
|
|
||||||
|
Consistent header for sections:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col items-center gap-2 mb-8">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl font-medium text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-6/10 text-lg leading-snug text-center"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Catalog
|
||||||
|
|
||||||
|
| Template | Theme | Navbar | siteBackground |
|
||||||
|
|----------|-------|--------|----------------|
|
||||||
|
| landscaping | Light Green | NavbarCentered | aurora |
|
||||||
|
| luxury-travel-agency | Light Beige | NavbarInline | cornerGlow |
|
||||||
|
| hvac | Light Blue | NavbarInline | lightRaysCenter |
|
||||||
|
| plumber | Dark Blue | NavbarInline | lightRaysCorner |
|
||||||
|
| roofing | Dark Orange | NavbarCentered | cornerGlow |
|
||||||
|
| saas | Dark Purple | NavbarInline | aurora |
|
||||||
|
| skincare | Light Sand | NavbarFloating | floatingGradient |
|
||||||
|
| dentist | Light Blue | NavbarInline | lightRaysCenter |
|
||||||
|
| detailing | Dark Orange | NavbarCentered | cornerGlow |
|
||||||
|
| real-estate | Light Elegant | NavbarFloating | gradientBars |
|
||||||
|
| skincare-luxury | Light Rose | NavbarDropdown | floatingGradient |
|
||||||
|
| med-spa | Light Mauve | NavbarCentered | horizonGlow |
|
||||||
|
| hotel | Dark Luxury | NavbarInline | none |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a Template
|
||||||
|
|
||||||
|
1. Create `/src/templates/[name]/page.tsx` and `theme.css`
|
||||||
|
2. Set up StyleProvider with appropriate background/button variants
|
||||||
|
3. Import sections from `/src/components/sections/` or template components
|
||||||
|
4. Configure theme.css with colors, buttons, and card styles
|
||||||
|
5. Use CDN URLs for all media assets
|
||||||
|
6. Add route in App.tsx
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
|
================================================================================
|
||||||
273
UI-COMPONENTS-RESEARCH.md
Normal file
273
UI-COMPONENTS-RESEARCH.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# UI Components Research
|
||||||
|
|
||||||
|
## Common Across All (48 components)
|
||||||
|
|
||||||
|
All three platforms (Lovable, Replit, Base44) share these - likely shadcn/ui base:
|
||||||
|
|
||||||
|
- accordion
|
||||||
|
- alert-dialog
|
||||||
|
- alert
|
||||||
|
- aspect-ratio
|
||||||
|
- avatar
|
||||||
|
- badge
|
||||||
|
- breadcrumb
|
||||||
|
- button
|
||||||
|
- calendar
|
||||||
|
- card
|
||||||
|
- carousel
|
||||||
|
- chart
|
||||||
|
- checkbox
|
||||||
|
- collapsible
|
||||||
|
- command
|
||||||
|
- context-menu
|
||||||
|
- dialog
|
||||||
|
- drawer
|
||||||
|
- dropdown-menu
|
||||||
|
- form
|
||||||
|
- hover-card
|
||||||
|
- input-otp
|
||||||
|
- input
|
||||||
|
- label
|
||||||
|
- menubar
|
||||||
|
- navigation-menu
|
||||||
|
- pagination
|
||||||
|
- popover
|
||||||
|
- progress
|
||||||
|
- radio-group
|
||||||
|
- resizable
|
||||||
|
- scroll-area
|
||||||
|
- select
|
||||||
|
- separator
|
||||||
|
- sheet
|
||||||
|
- sidebar
|
||||||
|
- skeleton
|
||||||
|
- slider
|
||||||
|
- sonner
|
||||||
|
- switch
|
||||||
|
- table
|
||||||
|
- tabs
|
||||||
|
- textarea
|
||||||
|
- toast
|
||||||
|
- toaster
|
||||||
|
- toggle-group
|
||||||
|
- toggle
|
||||||
|
- tooltip
|
||||||
|
|
||||||
|
## Replit-Only (7 unique)
|
||||||
|
|
||||||
|
- button-group
|
||||||
|
- empty
|
||||||
|
- field
|
||||||
|
- input-group
|
||||||
|
- item
|
||||||
|
- kbd
|
||||||
|
- spinner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lovable
|
||||||
|
|
||||||
|
- accordion
|
||||||
|
- alert-dialog
|
||||||
|
- alert
|
||||||
|
- aspect-ratio
|
||||||
|
- avatar
|
||||||
|
- badge
|
||||||
|
- breadcrumb
|
||||||
|
- button
|
||||||
|
- calendar
|
||||||
|
- card
|
||||||
|
- carousel
|
||||||
|
- chart
|
||||||
|
- checkbox
|
||||||
|
- collapsible
|
||||||
|
- command
|
||||||
|
- context-menu
|
||||||
|
- dialog
|
||||||
|
- drawer
|
||||||
|
- dropdown-menu
|
||||||
|
- form
|
||||||
|
- hover-card
|
||||||
|
- input-otp
|
||||||
|
- input
|
||||||
|
- label
|
||||||
|
- menubar
|
||||||
|
- navigation-menu
|
||||||
|
- pagination
|
||||||
|
- popover
|
||||||
|
- progress
|
||||||
|
- radio-group
|
||||||
|
- resizable
|
||||||
|
- scroll-area
|
||||||
|
- select
|
||||||
|
- separator
|
||||||
|
- sheet
|
||||||
|
- sidebar
|
||||||
|
- skeleton
|
||||||
|
- slider
|
||||||
|
- sonner (toast notifications)
|
||||||
|
- switch
|
||||||
|
- table
|
||||||
|
- tabs
|
||||||
|
- textarea
|
||||||
|
- toast
|
||||||
|
- toaster
|
||||||
|
- toggle-group
|
||||||
|
- toggle
|
||||||
|
- tooltip
|
||||||
|
|
||||||
|
## Replit
|
||||||
|
|
||||||
|
- accordion
|
||||||
|
- alert-dialog
|
||||||
|
- alert
|
||||||
|
- aspect-ratio
|
||||||
|
- avatar
|
||||||
|
- badge
|
||||||
|
- breadcrumb
|
||||||
|
- button-group
|
||||||
|
- button
|
||||||
|
- calendar
|
||||||
|
- card
|
||||||
|
- carousel
|
||||||
|
- chart
|
||||||
|
- checkbox
|
||||||
|
- collapsible
|
||||||
|
- command
|
||||||
|
- context-menu
|
||||||
|
- dialog
|
||||||
|
- drawer
|
||||||
|
- dropdown-menu
|
||||||
|
- empty
|
||||||
|
- field
|
||||||
|
- form
|
||||||
|
- hover-card
|
||||||
|
- input-group
|
||||||
|
- input-otp
|
||||||
|
- input
|
||||||
|
- item
|
||||||
|
- kbd (keyboard shortcut display)
|
||||||
|
- label
|
||||||
|
- menubar
|
||||||
|
- navigation-menu
|
||||||
|
- pagination
|
||||||
|
- popover
|
||||||
|
- progress
|
||||||
|
- radio-group
|
||||||
|
- resizable
|
||||||
|
- scroll-area
|
||||||
|
- select
|
||||||
|
- separator
|
||||||
|
- sheet
|
||||||
|
- sidebar
|
||||||
|
- skeleton
|
||||||
|
- slider
|
||||||
|
- sonner
|
||||||
|
- spinner
|
||||||
|
- switch
|
||||||
|
- table
|
||||||
|
- tabs
|
||||||
|
- textarea
|
||||||
|
- toast
|
||||||
|
- toaster
|
||||||
|
- toggle-group
|
||||||
|
- toggle
|
||||||
|
- tooltip
|
||||||
|
|
||||||
|
## Base44
|
||||||
|
|
||||||
|
- accordion
|
||||||
|
- alert-dialog
|
||||||
|
- alert
|
||||||
|
- aspect-ratio
|
||||||
|
- avatar
|
||||||
|
- badge
|
||||||
|
- breadcrumb
|
||||||
|
- button
|
||||||
|
- calendar
|
||||||
|
- card
|
||||||
|
- carousel
|
||||||
|
- chart
|
||||||
|
- checkbox
|
||||||
|
- collapsible
|
||||||
|
- command
|
||||||
|
- context-menu
|
||||||
|
- dialog
|
||||||
|
- drawer
|
||||||
|
- dropdown-menu
|
||||||
|
- form
|
||||||
|
- hover-card
|
||||||
|
- input-otp
|
||||||
|
- input
|
||||||
|
- label
|
||||||
|
- menubar
|
||||||
|
- navigation-menu
|
||||||
|
- pagination
|
||||||
|
- popover
|
||||||
|
- progress
|
||||||
|
- radio-group
|
||||||
|
- resizable
|
||||||
|
- scroll-area
|
||||||
|
- select
|
||||||
|
- separator
|
||||||
|
- sheet
|
||||||
|
- sidebar
|
||||||
|
- skeleton
|
||||||
|
- slider
|
||||||
|
- sonner
|
||||||
|
- switch
|
||||||
|
- table
|
||||||
|
- tabs
|
||||||
|
- textarea
|
||||||
|
- toast
|
||||||
|
- toaster
|
||||||
|
- toggle-group
|
||||||
|
- toggle
|
||||||
|
- tooltip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analysis Summary
|
||||||
|
|
||||||
|
### Key Patterns from Competitors
|
||||||
|
|
||||||
|
1. **forwardRef** - All components use `React.forwardRef` for ref access
|
||||||
|
2. **CVA** - Class Variance Authority for variant management
|
||||||
|
3. **Compound exports** - Multiple sub-components per file
|
||||||
|
4. **Radix primitives** - 15+ Radix packages for accessibility
|
||||||
|
5. **CSS animations** - `data-[state=open]:animate-in` patterns
|
||||||
|
|
||||||
|
### Our Differences
|
||||||
|
|
||||||
|
| Aspect | Competitors | Our Approach | Why |
|
||||||
|
|--------|-------------|--------------|-----|
|
||||||
|
| Animation | CSS only | Framer Motion | More powerful for marketing sites |
|
||||||
|
| Variants | CVA | CSS classes | AI edits CSS directly |
|
||||||
|
| Exports | Compound | Single default | Simpler for AI |
|
||||||
|
| Backgrounds | None | 13 components | Unique value for marketing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
### 1. New Components to Add
|
||||||
|
|
||||||
|
| Component | Purpose | Approach |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
|
||||||
|
### 2. Dependencies to Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add embla-carousel-react react-day-picker
|
||||||
|
```
|
||||||
|
|
||||||
|
**No Radix dependencies** - Dropdown and Tooltip built from scratch with Framer Motion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Research Files
|
||||||
|
|
||||||
|
Competitor component files stored in:
|
||||||
|
- `research/base44/` - 49 JSX files
|
||||||
|
- `research/lovable/` - 48 TSX files
|
||||||
|
- `research/replit/` - 53 TSX files
|
||||||
598
colorThemes.css
Normal file
598
colorThemes.css
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
/* ============================================
|
||||||
|
COLOR THEMES - Generated from colorThemes.json
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEMES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/darkBlue */
|
||||||
|
/*
|
||||||
|
--background: #f5faff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #001122;
|
||||||
|
--primary-cta: #15479c;
|
||||||
|
--primary-cta-text: #f5faff;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #001122;
|
||||||
|
--accent: #a8cce8;
|
||||||
|
--background-accent: #7ba3cf;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/darkGreen */
|
||||||
|
/*
|
||||||
|
--background: #fafffb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #001a0a;
|
||||||
|
--primary-cta: #0a705f;
|
||||||
|
--primary-cta-text: #fafffb;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #001a0a;
|
||||||
|
--accent: #a8d9be;
|
||||||
|
--background-accent: #6bbfb8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/lightRed */
|
||||||
|
/*
|
||||||
|
--background: #fffafa;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1a0000;
|
||||||
|
--primary-cta: #e63946;
|
||||||
|
--primary-cta-text: #fffafa;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1a0000;
|
||||||
|
--accent: #f5c4c7;
|
||||||
|
--background-accent: #f09199;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/lightPurple */
|
||||||
|
/*
|
||||||
|
--background: #fbfaff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #0f0022;
|
||||||
|
--primary-cta: #8b5cf6;
|
||||||
|
--primary-cta-text: #fbfaff;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #0f0022;
|
||||||
|
--accent: #d8cef5;
|
||||||
|
--background-accent: #c4a8f9;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmCream */
|
||||||
|
/*
|
||||||
|
--background: #f6f0e9;
|
||||||
|
--card: #efe7dd;
|
||||||
|
--foreground: #2b180a;
|
||||||
|
--primary-cta: #2b180a;
|
||||||
|
--primary-cta-text: #f6f0e9;
|
||||||
|
--secondary-cta: #efe7dd;
|
||||||
|
--secondary-cta-text: #2b180a;
|
||||||
|
--accent: #94877c;
|
||||||
|
--background-accent: #afa094;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayBlueAccent */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #1c1c1c;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #15479c;
|
||||||
|
--background-accent: #a8cce8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayGreenAccent */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #1c1c1c;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #159c49;
|
||||||
|
--background-accent: #a8e8ba;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayRedAccent */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #1c1c1c;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #e63946;
|
||||||
|
--background-accent: #e8bea8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayPurpleAccent */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #1c1c1c;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #6139e6;
|
||||||
|
--background-accent: #b3a8e8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmBeige */
|
||||||
|
/*
|
||||||
|
--background: #efebe5;
|
||||||
|
--card: #f7f2ea;
|
||||||
|
--foreground: #000000;
|
||||||
|
--primary-cta: #000000;
|
||||||
|
--primary-cta-text: #efebe5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #000000;
|
||||||
|
--accent: #ffffff;
|
||||||
|
--background-accent: #e1b875;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayTealGreen */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #1f514c;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #159c49;
|
||||||
|
--background-accent: #a8e8ba;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayNavyBlue */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #1f3251;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #15479c;
|
||||||
|
--background-accent: #a8cce8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayBurgundyRed */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #511f1f;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #e63946;
|
||||||
|
--background-accent: #e8bea8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayIndigoPurple */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #341f51;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #6139e6;
|
||||||
|
--background-accent: #b3a8e8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmgrayPink */
|
||||||
|
/*
|
||||||
|
--background: #f7f6f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1b0c25;
|
||||||
|
--primary-cta: #1b0c25;
|
||||||
|
--primary-cta-text: #f7f6f7;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1b0c25;
|
||||||
|
--accent: #ff93e4;
|
||||||
|
--background-accent: #e8a8c3;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmgrayOrange */
|
||||||
|
/*
|
||||||
|
--background: #f7f6f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #25190c;
|
||||||
|
--primary-cta: #ff6207;
|
||||||
|
--primary-cta-text: #f7f6f7;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #25190c;
|
||||||
|
--accent: #ffce93;
|
||||||
|
--background-accent: #e8cfa8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmgrayBlue */
|
||||||
|
/*
|
||||||
|
--background: #f7f6f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #0c1325;
|
||||||
|
--primary-cta: #0798ff;
|
||||||
|
--primary-cta-text: #f7f6f7;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #0c1325;
|
||||||
|
--accent: #93c7ff;
|
||||||
|
--background-accent: #a8cde8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/lavenderPeach */
|
||||||
|
/*
|
||||||
|
--background: #e3deea;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #27231f;
|
||||||
|
--primary-cta: #27231f;
|
||||||
|
--primary-cta-text: #e3deea;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #27231f;
|
||||||
|
--accent: #c68a62;
|
||||||
|
--background-accent: #c68a62;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/lavenderBlue */
|
||||||
|
/*
|
||||||
|
--background: #e3deea;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1f2027;
|
||||||
|
--primary-cta: #1f2027;
|
||||||
|
--primary-cta-text: #e3deea;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1f2027;
|
||||||
|
--accent: #627dc6;
|
||||||
|
--background-accent: #627dc6;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmStone */
|
||||||
|
/*
|
||||||
|
--background: #f5f4ef;
|
||||||
|
--card: #dad6cd;
|
||||||
|
--foreground: #2a2928;
|
||||||
|
--primary-cta: #2a2928;
|
||||||
|
--primary-cta-text: #f5f4ef;
|
||||||
|
--secondary-cta: #ecebea;
|
||||||
|
--secondary-cta-text: #2a2928;
|
||||||
|
--accent: #ffffff;
|
||||||
|
--background-accent: #c6b180;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmStoneGray */
|
||||||
|
/*
|
||||||
|
--background: #f5f4f0;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1a1a1a;
|
||||||
|
--primary-cta: #2c2c2c;
|
||||||
|
--primary-cta-text: #f5f4f0;
|
||||||
|
--secondary-cta: #f5f4f0;
|
||||||
|
--secondary-cta-text: #1a1a1a;
|
||||||
|
--accent: #8a8a8a;
|
||||||
|
--background-accent: #e8e6e1;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmGreen */
|
||||||
|
/*
|
||||||
|
--background: #f6f7f4;
|
||||||
|
--card: #fffefe;
|
||||||
|
--foreground: #080908;
|
||||||
|
--primary-cta: #0e3a29;
|
||||||
|
--primary-cta-text: #fffefe;
|
||||||
|
--secondary-cta: #ebeee0;
|
||||||
|
--secondary-cta-text: #080908;
|
||||||
|
--accent: #35c18b;
|
||||||
|
--background-accent: #c6efc6;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmSand */
|
||||||
|
/*
|
||||||
|
--background: #fcf6ec;
|
||||||
|
--card: #f3ede2;
|
||||||
|
--foreground: #2e2521;
|
||||||
|
--primary-cta: #2e2521;
|
||||||
|
--primary-cta-text: #fcf6ec;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #2e2521;
|
||||||
|
--accent: #b2a28b;
|
||||||
|
--background-accent: #b2a28b;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/warmgrayRed */
|
||||||
|
/*
|
||||||
|
--background: #f7f6f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #250c0d;
|
||||||
|
--primary-cta: #b82b40;
|
||||||
|
--primary-cta-text: #f7f6f7;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #250c0d;
|
||||||
|
--accent: #b90941;
|
||||||
|
--background-accent: #e8a8b6;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/lightTheme/grayBurgundyCoral */
|
||||||
|
/*
|
||||||
|
--background: #f5f5f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--foreground: #1c1c1c;
|
||||||
|
--primary-cta: #511f1f;
|
||||||
|
--primary-cta-text: #f5f5f5;
|
||||||
|
--secondary-cta: #ffffff;
|
||||||
|
--secondary-cta-text: #1c1c1c;
|
||||||
|
--accent: #8f3838;
|
||||||
|
--background-accent: #c9725c;
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK THEMES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/minimal */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #ffffffe6;
|
||||||
|
--primary-cta: #e6e6e6;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #ffffffe6;
|
||||||
|
--accent: #737373;
|
||||||
|
--background-accent: #737373;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/minimalLightBlue */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #f0f8ffe6;
|
||||||
|
--primary-cta: #cee7ff;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #f0f8ffe6;
|
||||||
|
--accent: #737373;
|
||||||
|
--background-accent: #737373;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/minimalLightGreen */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #f5fffae6;
|
||||||
|
--primary-cta: #80da9b;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #f5fffae6;
|
||||||
|
--accent: #737373;
|
||||||
|
--background-accent: #737373;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/minimalLightRed */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #fff5f5e6;
|
||||||
|
--primary-cta: #ff7a7a;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #fff5f5e6;
|
||||||
|
--accent: #737373;
|
||||||
|
--background-accent: #737373;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/minimalLightPurple */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #f8f5ffe6;
|
||||||
|
--primary-cta: #c89bff;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #f8f5ffe6;
|
||||||
|
--accent: #737373;
|
||||||
|
--background-accent: #737373;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/lime */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #f5f5f5;
|
||||||
|
--primary-cta: #dfff1c;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #ffffff;
|
||||||
|
--accent: #8b9a1b;
|
||||||
|
--background-accent: #5d6b00;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/gold */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #f5f5f5;
|
||||||
|
--primary-cta: #ffdf7d;
|
||||||
|
--primary-cta-text: #0a0a0a;
|
||||||
|
--secondary-cta: #1a1a1a;
|
||||||
|
--secondary-cta-text: #ffffff;
|
||||||
|
--accent: #b8860b;
|
||||||
|
--background-accent: #8b6914;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/midnightBlue */
|
||||||
|
/*
|
||||||
|
--background: #000000;
|
||||||
|
--card: #0c0c0c;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--primary-cta: #106EFB;
|
||||||
|
--primary-cta-text: #ffffff;
|
||||||
|
--secondary-cta: #000000;
|
||||||
|
--secondary-cta-text: #ffffff;
|
||||||
|
--accent: #535353;
|
||||||
|
--background-accent: #106EFB;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/blueOrangeAccent */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--primary-cta: #1f7cff;
|
||||||
|
--primary-cta-text: #ffffff;
|
||||||
|
--secondary-cta: #010101;
|
||||||
|
--secondary-cta-text: #ffffff;
|
||||||
|
--accent: #1f7cff;
|
||||||
|
--background-accent: #f96b2f;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/minimalBrightOrange */
|
||||||
|
/*
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--primary-cta: #e34400;
|
||||||
|
--primary-cta-text: #ffffff;
|
||||||
|
--secondary-cta: #010101;
|
||||||
|
--secondary-cta-text: #ffffff;
|
||||||
|
--accent: #737373;
|
||||||
|
--background-accent: #e34400;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/lightBlue */
|
||||||
|
/*
|
||||||
|
--background: #010912;
|
||||||
|
--card: #152840;
|
||||||
|
--foreground: #e6f0ff;
|
||||||
|
--primary-cta: #cee7ff;
|
||||||
|
--primary-cta-text: #010912;
|
||||||
|
--secondary-cta: #0e1a29;
|
||||||
|
--secondary-cta-text: #e6f0ff;
|
||||||
|
--accent: #3f5c79;
|
||||||
|
--background-accent: #004a93;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/lightGreen */
|
||||||
|
/*
|
||||||
|
--background: #000802;
|
||||||
|
--card: #0b1a0b;
|
||||||
|
--foreground: #e6ffe6;
|
||||||
|
--primary-cta: #80da9b;
|
||||||
|
--primary-cta-text: #000802;
|
||||||
|
--secondary-cta: #07170b;
|
||||||
|
--secondary-cta-text: #e6ffe6;
|
||||||
|
--accent: #38714a;
|
||||||
|
--background-accent: #2c6541;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/lightRed */
|
||||||
|
/*
|
||||||
|
--background: #080000;
|
||||||
|
--card: #1e0d0d;
|
||||||
|
--foreground: #ffe6e6;
|
||||||
|
--primary-cta: #ff7a7a;
|
||||||
|
--primary-cta-text: #080000;
|
||||||
|
--secondary-cta: #1e0909;
|
||||||
|
--secondary-cta-text: #ffe6e6;
|
||||||
|
--accent: #7b4242;
|
||||||
|
--background-accent: #65292c;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/darkRed */
|
||||||
|
/*
|
||||||
|
--background: #060000;
|
||||||
|
--card: #1d0d0d;
|
||||||
|
--foreground: #ffe6e6;
|
||||||
|
--primary-cta: #ff3d4a;
|
||||||
|
--primary-cta-text: #ffffff;
|
||||||
|
--secondary-cta: #1f0a0a;
|
||||||
|
--secondary-cta-text: #ffe6e6;
|
||||||
|
--accent: #7b2d2d;
|
||||||
|
--background-accent: #b8111f;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/lightPurple */
|
||||||
|
/*
|
||||||
|
--background: #050012;
|
||||||
|
--card: #040121;
|
||||||
|
--foreground: #f0e6ff;
|
||||||
|
--primary-cta: #c89bff;
|
||||||
|
--primary-cta-text: #050012;
|
||||||
|
--secondary-cta: #1d123b;
|
||||||
|
--secondary-cta-text: #f0e6ff;
|
||||||
|
--accent: #684f7b;
|
||||||
|
--background-accent: #65417c;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/lightOrange */
|
||||||
|
/*
|
||||||
|
--background: #080200;
|
||||||
|
--card: #1a0d0b;
|
||||||
|
--foreground: #ffe6d5;
|
||||||
|
--primary-cta: #ffaa70;
|
||||||
|
--primary-cta-text: #080200;
|
||||||
|
--secondary-cta: #170b07;
|
||||||
|
--secondary-cta-text: #ffe6d5;
|
||||||
|
--accent: #7b5e4a;
|
||||||
|
--background-accent: #b8541e;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/deepBlue */
|
||||||
|
/*
|
||||||
|
--background: #020617;
|
||||||
|
--card: #0f172a;
|
||||||
|
--foreground: #e2e8f0;
|
||||||
|
--primary-cta: #c4d8f9;
|
||||||
|
--primary-cta-text: #020617;
|
||||||
|
--secondary-cta: #041633;
|
||||||
|
--secondary-cta-text: #e2e8f0;
|
||||||
|
--accent: #2d30f3;
|
||||||
|
--background-accent: #1d4ed8;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/violet */
|
||||||
|
/*
|
||||||
|
--background: #030128;
|
||||||
|
--card: #241f48;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--primary-cta: #ffffff;
|
||||||
|
--primary-cta-text: #030128;
|
||||||
|
--secondary-cta: #131136;
|
||||||
|
--secondary-cta-text: #d5d4f6;
|
||||||
|
--accent: #44358a;
|
||||||
|
--background-accent: #b597fe;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/ruby */
|
||||||
|
/*
|
||||||
|
--background: #000000;
|
||||||
|
--card: #481f1f;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--primary-cta: #ffffff;
|
||||||
|
--primary-cta-text: #280101;
|
||||||
|
--secondary-cta: #361311;
|
||||||
|
--secondary-cta-text: #f6d4d4;
|
||||||
|
--accent: #51000b;
|
||||||
|
--background-accent: #ff2231;
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* @colorThemes/darkTheme/emerald */
|
||||||
|
/*
|
||||||
|
--background: #000000;
|
||||||
|
--card: #1f4035;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--primary-cta: #ffffff;
|
||||||
|
--primary-cta-text: #051a12;
|
||||||
|
--secondary-cta: #0d2b1f;
|
||||||
|
--secondary-cta-text: #d4f6e8;
|
||||||
|
--accent: #0d5238;
|
||||||
|
--background-accent: #10b981;
|
||||||
|
*/
|
||||||
|
|
||||||
501
colorThemes.json
Normal file
501
colorThemes.json
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
{
|
||||||
|
"lightTheme": {
|
||||||
|
"darkBlue": {
|
||||||
|
"--background": "#f5faff",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--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": "#ffffff",
|
||||||
|
"--foreground": "#001a0a",
|
||||||
|
"--primary-cta": "#0a705f",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#a8d9be",
|
||||||
|
"--background-accent": "#6bbfb8",
|
||||||
|
"--primary-cta-text": "#fafffb",
|
||||||
|
"--secondary-cta-text": "#001a0a"
|
||||||
|
},
|
||||||
|
"lightRed": {
|
||||||
|
"--background": "#fffafa",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--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": "#ffffff",
|
||||||
|
"--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"
|
||||||
|
},
|
||||||
|
"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": "#f6f7f4",
|
||||||
|
"--card": "#fffefe",
|
||||||
|
"--foreground": "#080908",
|
||||||
|
"--primary-cta": "#0e3a29",
|
||||||
|
"--secondary-cta": "#ebeee0",
|
||||||
|
"--accent": "#35c18b",
|
||||||
|
"--background-accent": "#c6efc6",
|
||||||
|
"--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"
|
||||||
|
},
|
||||||
|
"grayBurgundyCoral": {
|
||||||
|
"--background": "#f5f5f5",
|
||||||
|
"--card": "#ffffff",
|
||||||
|
"--foreground": "#1c1c1c",
|
||||||
|
"--primary-cta": "#511f1f",
|
||||||
|
"--secondary-cta": "#ffffff",
|
||||||
|
"--accent": "#8f3838",
|
||||||
|
"--background-accent": "#c9725c",
|
||||||
|
"--primary-cta-text": "#f5f5f5",
|
||||||
|
"--secondary-cta-text": "#1c1c1c"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Webild Components</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "webild-components",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"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",
|
||||||
|
"embla-carousel-wheel-gestures": "^8.1.0",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
|
"lenis": "^1.3.23",
|
||||||
|
"lucide-react": "0.575.0",
|
||||||
|
"motion": "^12.38.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2333
pnpm-lock.yaml
generated
Normal file
2333
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 |
2408
registry.json
Normal file
2408
registry.json
Normal file
File diff suppressed because it is too large
Load Diff
243
src/App.tsx
Normal file
243
src/App.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
|
import { Award, Leaf, Milk } from "lucide-react";
|
||||||
|
|
||||||
|
import NavbarFloating from "@/components/ui/NavbarFloating";
|
||||||
|
import HeroOverlay from "@/components/sections/hero/HeroOverlay";
|
||||||
|
import AboutMediaOverlay from "@/components/sections/about/AboutMediaOverlay";
|
||||||
|
import FeaturesRevealCards from "@/components/sections/features/FeaturesRevealCards";
|
||||||
|
import FeaturesRevealCardsBento from "@/components/sections/features/FeaturesRevealCardsBento";
|
||||||
|
import MetricsIconCards from "@/components/sections/metrics/MetricsIconCards";
|
||||||
|
import TestimonialMarqueeCards from "@/components/sections/testimonial/TestimonialMarqueeCards";
|
||||||
|
import SocialProofMarquee from "@/components/sections/social-proof/SocialProofMarquee";
|
||||||
|
import FaqTwoColumn from "@/components/sections/faq/FaqTwoColumn";
|
||||||
|
import ContactCta from "@/components/sections/contact/ContactCta";
|
||||||
|
import FooterBasic from "@/components/sections/footer/FooterBasic";
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
defaultButtonVariant="outline"
|
||||||
|
defaultTextAnimation="slide-up"
|
||||||
|
borderRadius="soft"
|
||||||
|
contentWidth="mediumLarge"
|
||||||
|
sizing="base"
|
||||||
|
background="solid"
|
||||||
|
cardStyle="glass-depth"
|
||||||
|
primaryButtonStyle="metallic"
|
||||||
|
secondaryButtonStyle="solid"
|
||||||
|
headingFontWeight="bold"
|
||||||
|
>
|
||||||
|
<div id="nav" data-section="nav">
|
||||||
|
<NavbarFloating
|
||||||
|
brandName="Alba Dairy"
|
||||||
|
navItems={[
|
||||||
|
{ name: "Home", href: "#home" },
|
||||||
|
{ name: "About", href: "#about" },
|
||||||
|
{ name: "Products", href: "#products" },
|
||||||
|
{ name: "Benefits", href: "#benefits" },
|
||||||
|
{ name: "Testimonials", href: "#testimonials" },
|
||||||
|
{ name: "Contact", href: "#contact" }
|
||||||
|
]}
|
||||||
|
ctaButton={{ text: "Find a Store", href: "#contact" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="home" data-section="home" data-webild-component="HeroOverlay" data-webild-component-path="@/components/sections/hero/HeroOverlay">
|
||||||
|
<HeroOverlay
|
||||||
|
tag="Experience True Danish Quality"
|
||||||
|
title="Alba Dairy: Authentic Greek Yogurt, Fresh from Denmark"
|
||||||
|
description="Crafted with the finest local milk and time-honored traditions, Alba Dairy brings you the richest, creamiest Greek yogurt experience. Pure, natural, and incredibly delicious."
|
||||||
|
primaryButton={{ text: "Explore Products", href: "#products" }}
|
||||||
|
secondaryButton={{ text: "Our Story", href: "#about" }}
|
||||||
|
imageSrc="http://img.b2bpic.net/free-photo/close-up-table-full-food_23-2149260990.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="about" data-section="about" data-webild-component="AboutMediaOverlay" data-webild-component-path="@/components/sections/about/AboutMediaOverlay">
|
||||||
|
<AboutMediaOverlay
|
||||||
|
tag="Our Heritage"
|
||||||
|
title="Generations of Dairy Excellence"
|
||||||
|
description="Nestled in the heart of Denmark, Alba Dairy combines generations of family tradition with modern sustainable practices. We believe in nurturing our land, caring for our cows, and crafting dairy products that truly nourish. Every pot of Alba Greek Yogurt is a testament to our unwavering commitment to quality and natural goodness."
|
||||||
|
primaryButton={{ text: "Learn More", href: "#" }}
|
||||||
|
imageSrc="http://img.b2bpic.net/free-photo/front-view-man-with-bottle-goats-milk_23-2148673058.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="benefits" data-section="benefits" data-webild-component="FeaturesRevealCards" data-webild-component-path="@/components/sections/features/FeaturesRevealCards">
|
||||||
|
<FeaturesRevealCards
|
||||||
|
tag="Why Alba?"
|
||||||
|
title="Pure Goodness, Unmatched Benefits"
|
||||||
|
description="Discover what makes Alba Dairy Greek Yogurt a staple for a healthy and delicious lifestyle. From high protein to rich probiotics, every spoonful delivers exceptional value."
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: "High Protein Content", description: "Each serving is packed with essential proteins to support muscle growth and keep you feeling full and energized throughout your day.", imageSrc: "http://img.b2bpic.net/free-photo/person-holding-spoon-with-milk-bowl_23-2147961708.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rich in Probiotics", description: "Our slow fermentation process ensures a thriving culture of live and active probiotics, vital for a healthy digestive system and overall well-being.", imageSrc: "http://img.b2bpic.net/free-photo/yogurt-with-mixed-berries_1339-7905.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "All-Natural Ingredients", description: "Made with only the finest Danish milk and natural fruits, our yogurt contains no artificial flavors, preservatives, or added sugars. Pure taste, pure health.", imageSrc: "http://img.b2bpic.net/free-photo/close-up-yogurt-with-berries_23-2147753683.jpg"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="products" data-section="products" data-webild-component="FeaturesRevealCardsBento" data-webild-component-path="@/components/sections/features/FeaturesRevealCardsBento">
|
||||||
|
<FeaturesRevealCardsBento
|
||||||
|
tag="Our Range"
|
||||||
|
title="Discover Your Favorite Alba Flavor"
|
||||||
|
description="From classic natural to delightful fruit fusions, our Greek yogurt collection offers something for every palate. Indulge in creamy perfection."
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: "Natural Unsweetened", description: "The pure, tangy taste of traditional Greek yogurt. Perfect on its own or as a versatile culinary ingredient.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/assortment-delicious-breakfast-meal-with-yogurt_23-2148894743.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Strawberry Delight", description: "Sweet, ripe Danish strawberries blended into our creamy yogurt for a burst of fruity freshness.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/different-fresh-berries-inside-white-cups-light-desk_140725-32141.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Blueberry Harvest", description: "Succulent blueberries from Nordic forests married with our thick, protein-rich Greek yogurt.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/bowl-with-yogurt-blueberries-high-angle_23-2149053153.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Golden Honey", description: "Infused with a touch of golden Danish honey for a subtly sweet and luxurious experience.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/milk-honey-pot-with-baked-biscuits-cork-coaster_23-2147918932.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Vanilla Dream", description: "The timeless elegance of natural vanilla bean swirled into our signature creamy Greek yogurt.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/mug-with-tasty-dessert_23-2147764942.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Peach Orchard", description: "Juicy, sun-ripened peaches add a refreshing and vibrant twist to our classic Greek yogurt.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/milk-chocolate-ice-cream-sphere-presentation_23-2151950776.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tropical Mango", description: "Escape to the tropics with the sweet, exotic flavor of ripe mangoes in every creamy spoonful.", href: "#", imageSrc: "http://img.b2bpic.net/free-photo/front-view-simple-pumpkin-soup-purple-tissue-dark-desk-thanksgiving-dine-smooth_140725-73038.jpg"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="quality" data-section="quality" data-webild-component="MetricsIconCards" data-webild-component-path="@/components/sections/metrics/MetricsIconCards">
|
||||||
|
<MetricsIconCards
|
||||||
|
tag="Our Commitment"
|
||||||
|
title="Quality You Can Taste & Trust"
|
||||||
|
description="At Alba Dairy, excellence is not just a goal, it's our promise. We meticulously oversee every step of our production process to bring you the best."
|
||||||
|
metrics={[
|
||||||
|
{ icon: Milk, title: "Local Danish Milk", value: "100%" },
|
||||||
|
{ icon: Leaf, title: "Organic Certified", value: "Yes" },
|
||||||
|
{ icon: Award, title: "Award-Winning Taste", value: "5 Stars" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="testimonials" data-section="testimonials" data-webild-component="TestimonialMarqueeCards" data-webild-component-path="@/components/sections/testimonial/TestimonialMarqueeCards">
|
||||||
|
<TestimonialMarqueeCards
|
||||||
|
tag="What Our Customers Say"
|
||||||
|
title="Loved by Families Across Denmark"
|
||||||
|
description="Hear directly from those who enjoy Alba Dairy Greek Yogurt every day. Their smiles are our greatest reward."
|
||||||
|
testimonials={[
|
||||||
|
{
|
||||||
|
name: "Sarah J.", role: "Busy Mom", quote: "Alba Dairy Greek Yogurt is a lifesaver for our family! It's so creamy and delicious, and I love that it's packed with protein for my active kids.", imageSrc: "http://img.b2bpic.net/free-photo/attractive-young-cheerful-girl-baking-kitchen-making-dough-holding-recipe-book-having-ideas_1258-195133.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mads L.", role: "Fitness Enthusiast", quote: "As someone who prioritizes nutrition, Alba's high-protein yogurt is perfect. It's incredibly satisfying and helps me recover after my workouts.", imageSrc: "http://img.b2bpic.net/free-photo/lifestyle-person-suffering-from-emotional-numbness_23-2151169282.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Anya P.", role: "Yogurt Connoisseur", quote: "I've tried many Greek yogurts, but Alba's is truly superior. The natural flavor is so pure, and the texture is simply divine.", imageSrc: "http://img.b2bpic.net/free-photo/senior-woman-enjoying-homemade-figs-dish_23-2150304436.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Frederik B.", role: "Student", quote: "Quick, easy, and healthy! Alba Dairy Greek Yogurt is my go-to snack between classes. Keeps me full and focused.", imageSrc: "http://img.b2bpic.net/free-photo/healthy-school-lunch-box-with-beef-sandwich-fresh-vegetables-bottle-water-fruits-blue-table-top-view-flat-lay_2829-17289.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Clara S.", role: "Retired Teacher", quote: "I appreciate the quality ingredients and the authentic taste. It reminds me of the traditional yogurts from my childhood. A delightful treat!", imageSrc: "http://img.b2bpic.net/free-photo/father-with-his-son-having-picnic-park_1303-16240.jpg"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="partners" data-section="partners" data-webild-component="SocialProofMarquee" data-webild-component-path="@/components/sections/social-proof/SocialProofMarquee">
|
||||||
|
<SocialProofMarquee
|
||||||
|
tag="Certified & Trusted"
|
||||||
|
title="Partnering for Purity and Excellence"
|
||||||
|
description="Alba Dairy is proud to collaborate with leading organizations and uphold the highest standards in dairy production."
|
||||||
|
names={[
|
||||||
|
"Danish Organic", "EU Organic", "Nordic Dairy Council", "Danish Food Safety", "Quality Denmark", "Global Food Awards", "Sustainable Dairy Alliance"
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="faq" data-section="faq" data-webild-component="FaqTwoColumn" data-webild-component-path="@/components/sections/faq/FaqTwoColumn">
|
||||||
|
<FaqTwoColumn
|
||||||
|
tag="Got Questions?"
|
||||||
|
title="Your Answers About Alba Dairy"
|
||||||
|
description="Find quick answers to the most common questions about our products, ingredients, and company practices."
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
question: "What makes Alba Dairy Greek Yogurt different?", answer: "Alba Dairy Greek Yogurt is made using a traditional straining process, resulting in a thicker, creamier texture and higher protein content. We use 100% fresh Danish milk and all-natural ingredients, free from artificial additives."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Are your yogurts organic?", answer: "Yes, many of our products are certified organic. Look for the organic label on the packaging to identify these options. We are committed to sustainable and organic farming practices."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Where does your milk come from?", answer: "Our milk is sourced exclusively from local, trusted dairy farms across Denmark, ensuring freshness, quality, and support for our local community."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Do your yogurts contain live cultures?", answer: "Absolutely! All Alba Dairy Greek Yogurts contain live and active cultures, including Lactobacillus bulgaricus and Streptococcus thermophilus, which are beneficial for gut health."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Are there any artificial ingredients or added sugars?", answer: "We pride ourselves on using only natural ingredients. Our yogurts are free from artificial flavors, colors, preservatives, and high-fructose corn syrup. Our 'Natural' variety contains no added sugars."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Is Alba Dairy Greek Yogurt suitable for children?", answer: "Yes, our natural and fruit-flavored yogurts are a great source of protein and calcium for growing children. We recommend checking individual product labels for specific nutritional information and ingredients."
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="contact" data-section="contact" data-webild-component="ContactCta" data-webild-component-path="@/components/sections/contact/ContactCta">
|
||||||
|
<ContactCta
|
||||||
|
tag="Get in Touch"
|
||||||
|
title="Have questions or want to partner with us? Reach out!"
|
||||||
|
text="Have questions or want to partner with us? Reach out!"
|
||||||
|
primaryButton={{ text: "Contact Us", href: "mailto:info@albadairy.dk" }}
|
||||||
|
secondaryButton={{ text: "Our Locations", href: "#" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="footer" data-section="footer" data-webild-component="FooterBasic" data-webild-component-path="@/components/sections/footer/FooterBasic">
|
||||||
|
<FooterBasic
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Products", items: [
|
||||||
|
{ label: "Natural", href: "#products" },
|
||||||
|
{ label: "Fruit Flavors", href: "#products" },
|
||||||
|
{ label: "Seasonal Editions", href: "#products" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Company", items: [
|
||||||
|
{ label: "Our Story", href: "#about" },
|
||||||
|
{ label: "Sustainability", href: "#" },
|
||||||
|
{ label: "Careers", href: "#" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Support", items: [
|
||||||
|
{ label: "FAQ", href: "#faq" },
|
||||||
|
{ label: "Contact Us", href: "#contact" },
|
||||||
|
{ label: "Shipping & Returns", href: "#" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Legal", items: [
|
||||||
|
{ label: "Privacy Policy", href: "#" },
|
||||||
|
{ label: "Terms of Service", href: "#" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
leftText="© 2023 Alba Dairy. All rights reserved."
|
||||||
|
rightText="Made with care in Denmark."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/Layout.tsx
Normal file
48
src/components/Layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import NavbarCentered from '@/components/ui/NavbarCentered';
|
||||||
|
import FooterSimple from '@/components/sections/footer/FooterSimple';
|
||||||
|
import { StyleProvider } from '@/components/ui/StyleProvider';
|
||||||
|
import SiteBackgroundSlot from '@/components/ui/SiteBackgroundSlot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared site chrome — navbar + footer rendered ONCE around every page via <Outlet />.
|
||||||
|
* Navbar renders exactly one source of truth; pages render only their own content.
|
||||||
|
*
|
||||||
|
* navItems is an EXPLICIT list — not derived from routes.ts — because it must support
|
||||||
|
* BOTH types of links:
|
||||||
|
* - page links: href: "/products", href: "/about"
|
||||||
|
* - anchor links: href: "#features", href: "#pricing", href: "#contact"
|
||||||
|
* When the initial site has everything on one page, anchors are the right thing.
|
||||||
|
* When pages are split, both coexist.
|
||||||
|
*
|
||||||
|
* Generator & page-create handler edit THIS array when user requests nav changes.
|
||||||
|
*
|
||||||
|
* StyleProvider props (buttonVariant / siteBackground / heroBackground) are the single
|
||||||
|
* source of truth for Button look and decorative backgrounds across the whole site.
|
||||||
|
* Backend writes them at generation time; Bob-AI's style handler edits them on demand.
|
||||||
|
*/
|
||||||
|
export default function Layout() {
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'Home', href: '/' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyleProvider buttonVariant="default" siteBackground="none" heroBackground="none">
|
||||||
|
<SiteBackgroundSlot />
|
||||||
|
<NavbarCentered
|
||||||
|
logo="Brand"
|
||||||
|
navItems={navItems}
|
||||||
|
ctaButton={{ text: 'Get Started', href: '/' }}
|
||||||
|
/>
|
||||||
|
<main className="flex-grow">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<FooterSimple
|
||||||
|
brand="Brand"
|
||||||
|
copyright="© 2024 Brand. All rights reserved."
|
||||||
|
columns={[]}
|
||||||
|
links={[]}
|
||||||
|
/>
|
||||||
|
</StyleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/ecommerce/ProductCart.tsx
Normal file
126
src/components/ecommerce/ProductCart.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { X, Plus, Minus, Trash2 } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type CartItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
quantity: number;
|
||||||
|
imageSrc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductCartProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
items: CartItem[];
|
||||||
|
total: string;
|
||||||
|
onQuantityChange?: (id: string, quantity: number) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
onCheckout?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductCart = ({ isOpen, onClose, items, total, onQuantityChange, onRemove, onCheckout }: ProductCartProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = isOpen ? "hidden" : "";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-1001">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="absolute inset-0 bg-foreground/50"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
className="card absolute right-0 top-0 flex flex-col p-5 h-screen w-screen md:w-96"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">Cart ({items.length})</h2>
|
||||||
|
<button onClick={onClose} className="card flex items-center justify-center size-8 rounded cursor-pointer" aria-label="Close cart">
|
||||||
|
<X className="size-4 text-foreground" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 h-px w-full bg-foreground/5" />
|
||||||
|
|
||||||
|
<div className="flex-1 py-5 min-h-0 overflow-y-auto">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-20 text-center text-sm text-foreground/50">Your cart is empty</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex gap-4">
|
||||||
|
<div className="shrink-0 size-24 overflow-hidden rounded">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col justify-between min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-base font-semibold text-foreground truncate">{item.name}</h3>
|
||||||
|
<p className="shrink-0 text-base font-semibold text-foreground">{item.price}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => item.quantity > 1 && onQuantityChange?.(item.id, item.quantity - 1)}
|
||||||
|
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<Minus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-5 text-center text-sm font-semibold text-foreground">{item.quantity}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onQuantityChange?.(item.id, item.quantity + 1)}
|
||||||
|
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove?.(item.id)}
|
||||||
|
className="card flex items-center justify-center ml-auto size-8 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-foreground" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="h-px w-full bg-foreground/5" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-base font-semibold text-foreground">Total</span>
|
||||||
|
<span className="text-base font-semibold text-foreground">{total}</span>
|
||||||
|
</div>
|
||||||
|
<Button text="Checkout" onClick={onCheckout} variant="primary" className="w-full" />
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCart;
|
||||||
|
export type { CartItem };
|
||||||
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Star, ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import useProducts from "@/hooks/useProducts";
|
||||||
|
import type { ProductVariant } from "./ProductDetailCard";
|
||||||
|
|
||||||
|
type CatalogProduct = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
imageSrc: string;
|
||||||
|
category?: string;
|
||||||
|
rating?: number;
|
||||||
|
reviewCount?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductCatalogProps = {
|
||||||
|
products?: CatalogProduct[];
|
||||||
|
searchValue?: string;
|
||||||
|
onSearchChange?: (value: string) => void;
|
||||||
|
filters?: ProductVariant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductCatalog = ({ products: productsProp, searchValue = "", onSearchChange, filters }: ProductCatalogProps) => {
|
||||||
|
const { products: fetchedProducts, isLoading } = useProducts();
|
||||||
|
|
||||||
|
const products: CatalogProduct[] = productsProp && productsProp.length > 0
|
||||||
|
? productsProp
|
||||||
|
: fetchedProducts.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
price: p.price,
|
||||||
|
imageSrc: p.imageSrc,
|
||||||
|
category: p.brand,
|
||||||
|
rating: p.rating,
|
||||||
|
reviewCount: p.reviewCount,
|
||||||
|
onClick: p.onProductClick,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isLoading && (!productsProp || productsProp.length === 0)) {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto py-20 w-content-width">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Loader2 className="size-8 text-foreground animate-spin" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto py-20 w-content-width">
|
||||||
|
{(onSearchChange || (filters && filters.length > 0)) && (
|
||||||
|
<div className="flex flex-col gap-5 mb-5 md:flex-row md:items-end">
|
||||||
|
{onSearchChange && (
|
||||||
|
<div className="flex flex-1 flex-col gap-2 min-w-32">
|
||||||
|
<label className="text-sm font-semibold text-foreground">Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search products..."
|
||||||
|
className="card px-4 h-9 w-full md:w-80 text-base text-foreground bg-transparent rounded focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filters && filters.length > 0 && (
|
||||||
|
<div className="flex gap-5 items-end">
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<div key={filter.label} className="flex flex-col gap-2 min-w-32">
|
||||||
|
<label className="text-sm font-semibold text-foreground">{filter.label}</label>
|
||||||
|
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||||
|
<select
|
||||||
|
value={filter.selected}
|
||||||
|
onChange={(e) => filter.onChange(e.target.value)}
|
||||||
|
className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none"
|
||||||
|
>
|
||||||
|
{filter.options.map((option) => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="py-20 text-center text-sm text-foreground/50">No products found</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{products.map((product) => (
|
||||||
|
<button
|
||||||
|
key={product.id}
|
||||||
|
onClick={product.onClick}
|
||||||
|
className="card group h-full flex flex-col gap-3 p-3 text-left rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={product.imageSrc} className="size-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center transition-all duration-300 group-hover:bg-background/20 group-hover:backdrop-blur-xs">
|
||||||
|
<div className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100">
|
||||||
|
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{product.category && (
|
||||||
|
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.category}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground truncate">{product.name}</h3>
|
||||||
|
{product.rating && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={cls("size-4 text-accent", i < Math.floor(product.rating || 0) ? "fill-accent" : "opacity-20")}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{product.reviewCount && (
|
||||||
|
<span className="text-sm text-foreground">({product.reviewCount})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold text-foreground">{product.price}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCatalog;
|
||||||
|
export type { CatalogProduct };
|
||||||
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import Transition from "@/components/ui/Transition";
|
||||||
|
|
||||||
|
type ProductVariant = {
|
||||||
|
label: string;
|
||||||
|
options: string[];
|
||||||
|
selected: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProductDetailCardProps = {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
salePrice?: string;
|
||||||
|
images: string[];
|
||||||
|
description?: string;
|
||||||
|
rating?: number;
|
||||||
|
ribbon?: string;
|
||||||
|
inventoryStatus?: "in-stock" | "out-of-stock";
|
||||||
|
inventoryQuantity?: number;
|
||||||
|
sku?: string;
|
||||||
|
variants?: ProductVariant[];
|
||||||
|
quantity?: ProductVariant;
|
||||||
|
onAddToCart?: () => void;
|
||||||
|
onBuyNow?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
|
||||||
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto py-20 w-content-width">
|
||||||
|
<div className="flex flex-col gap-5 md:flex-row">
|
||||||
|
<div className="relative md:w-1/2">
|
||||||
|
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
|
||||||
|
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
|
||||||
|
</Transition>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
|
||||||
|
{images.map((src, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSelectedImage(i)}
|
||||||
|
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
|
||||||
|
<div className={cls(
|
||||||
|
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
|
||||||
|
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
|
||||||
|
<div className="flex items-start justify-between gap-5">
|
||||||
|
<h2 className="flex-1 text-2xl font-semibold text-foreground md:text-3xl">{name}</h2>
|
||||||
|
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-semibold rounded text-secondary-cta-text">{ribbon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-full bg-foreground/5" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xl font-semibold text-foreground md:text-2xl">
|
||||||
|
{salePrice ? (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground/75 line-through mr-1">{price}</span>
|
||||||
|
<span>{salePrice}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
price
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(inventoryStatus || inventoryQuantity || sku) && (
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
{inventoryStatus && (
|
||||||
|
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
|
||||||
|
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryQuantity && (
|
||||||
|
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
|
||||||
|
)}
|
||||||
|
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
|
||||||
|
{variants && variants.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-5">
|
||||||
|
{variants.map((variant) => (
|
||||||
|
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
|
||||||
|
<label className="text-sm font-semibold text-foreground">{variant.label}</label>
|
||||||
|
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||||
|
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||||
|
{variant.options.map((option) => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quantity && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-semibold text-foreground">{quantity.label}</label>
|
||||||
|
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
|
||||||
|
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||||
|
{quantity.options.map((option) => (
|
||||||
|
<option key={option} value={option}>{option}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col mt-auto gap-3 pt-5">
|
||||||
|
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
|
||||||
|
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductDetailCard;
|
||||||
|
export type { ProductVariant };
|
||||||
13
src/components/layouts/DefaultsLayout.tsx
Normal file
13
src/components/layouts/DefaultsLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
const DefaultsLayout = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add('use-defaults');
|
||||||
|
return () => document.body.classList.remove('use-defaults');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DefaultsLayout;
|
||||||
90
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
90
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type AboutFeaturesSplitProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: { icon: string | LucideIcon; title: string; description: string }[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const AboutFeaturesSplit = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: AboutFeaturesSplitProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="About section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10 mx-auto w-content-width">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
|
||||||
|
<div className="flex flex-col justify-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 w-full md:w-4/10 2xl:w-35/100 card rounded">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const ItemIcon = resolveIcon(item.icon);
|
||||||
|
return (
|
||||||
|
<div key={item.title}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
|
||||||
|
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
{index < items.length - 1 && (
|
||||||
|
<div className="mt-4 xl:mt-5 2xl:mt-6 border-b border-accent/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-px w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
|
||||||
|
<div className="relative size-full">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutFeaturesSplit;
|
||||||
72
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
72
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type AboutMediaOverlayProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const AboutMediaOverlay = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: AboutMediaOverlayProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="About section" className="py-20">
|
||||||
|
<div className="relative flex items-center justify-center py-8 md:py-12 mx-auto w-content-width rounded overflow-hidden">
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
<div className="absolute inset-0 bg-foreground/40 backdrop-blur-xs pointer-events-none select-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex items-center justify-center px-5 py-10 mx-auto min-h-100 md:min-h-120 md:w-1/2 w-content-width">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-15%" }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className="mb-1 px-3 py-1 text-sm card rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance text-primary-cta-text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="text-lg md:text-xl leading-snug text-balance text-primary-cta-text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutMediaOverlay;
|
||||||
86
src/components/sections/about/AboutParallax.tsx
Normal file
86
src/components/sections/about/AboutParallax.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { motion, useScroll, useTransform } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type AboutParallaxProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
badge?: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const AboutParallax = ({ tag, title, description, primaryButton, secondaryButton, imageSrc, videoSrc, badge }: AboutParallaxProps) => {
|
||||||
|
const sectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: sectionRef,
|
||||||
|
offset: ["start end", "end start"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fgY = useTransform(scrollYProgress, [0, 1], ["120px", "-120px"]);
|
||||||
|
const bgY = useTransform(scrollYProgress, [0, 1], ["-60px", "60px"]);
|
||||||
|
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={sectionRef}
|
||||||
|
aria-label="About section"
|
||||||
|
className="relative py-20"
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-content-width">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8 md:gap-16">
|
||||||
|
<div className="w-full md:w-45/100 flex flex-col gap-3">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-55/100 relative h-100 md:h-125 xl:h-150 2xl:h-175">
|
||||||
|
<div className="absolute top-0 right-0 w-75/100 h-full overflow-hidden rounded-none">
|
||||||
|
<motion.div className="w-full h-full" style={{ y: bgY, scale: bgScale }}>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
|
||||||
|
</motion.div>
|
||||||
|
{badge && (
|
||||||
|
<span className="absolute top-2 right-2 xl:top-3 xl:right-3 2xl:top-4 2xl:right-4 px-3 py-1.5 text-xs text-foreground font-medium card rounded-none">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-15/100 left-0 w-65/100 h-70/100 z-10 overflow-hidden rounded-none"
|
||||||
|
style={{ y: fgY }}
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutParallax;
|
||||||
58
src/components/sections/about/AboutTestimonial.tsx
Normal file
58
src/components/sections/about/AboutTestimonial.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Quote } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type AboutTestimonialProps = {
|
||||||
|
tag: string;
|
||||||
|
quote: string;
|
||||||
|
author: string;
|
||||||
|
role: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const AboutTestimonial = ({
|
||||||
|
tag,
|
||||||
|
quote,
|
||||||
|
author,
|
||||||
|
role,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: AboutTestimonialProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Testimonial section" className="py-20">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
|
||||||
|
<div className="relative md:col-span-3 p-10 md:p-20 card rounded">
|
||||||
|
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
|
||||||
|
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-col justify-center gap-5 h-full">
|
||||||
|
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={quote}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="h1"
|
||||||
|
className="text-4xl md:text-5xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-base font-medium truncate">{author}</span>
|
||||||
|
<span className="text-accent shrink-0">•</span>
|
||||||
|
<span className="text-base font-medium truncate">{role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up" className="p-px md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutTestimonial;
|
||||||
105
src/components/sections/about/AboutTestimonialParallax.tsx
Normal file
105
src/components/sections/about/AboutTestimonialParallax.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useScroll, useTransform, motion } from "motion/react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Quote } from "lucide-react";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type SocialLink = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AboutTestimonialParallaxProps = {
|
||||||
|
tag: string;
|
||||||
|
quote: string;
|
||||||
|
author: string;
|
||||||
|
role: string;
|
||||||
|
socialLinks?: SocialLink[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const SocialLinkButton = ({ icon, label, href, onClick }: SocialLink) => {
|
||||||
|
const Icon = resolveIcon(icon);
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onClick={handleClick}
|
||||||
|
className="flex items-center justify-center gap-2 h-9 px-3 text-sm rounded-full cursor-pointer backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20 text-primary-cta-text font-medium hover:bg-primary-cta-text/25 transition-all duration-300 ease-out"
|
||||||
|
>
|
||||||
|
<Icon className="size-4" strokeWidth={1.5} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutTestimonialParallax = ({
|
||||||
|
tag,
|
||||||
|
quote,
|
||||||
|
author,
|
||||||
|
role,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
socialLinks,
|
||||||
|
}: AboutTestimonialParallaxProps) => {
|
||||||
|
const imageRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: imageRef,
|
||||||
|
offset: ["start end", "end start"],
|
||||||
|
});
|
||||||
|
const imageScale = useTransform(scrollYProgress, [0, 0.6], [1.3, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="About section" className="py-20">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
|
||||||
|
<div className="relative md:col-span-3 p-10 md:p-20 card rounded">
|
||||||
|
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
|
||||||
|
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-col justify-center gap-5 h-full">
|
||||||
|
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={quote}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="h1"
|
||||||
|
className="text-4xl md:text-5xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-base font-medium truncate">{author}</span>
|
||||||
|
<span className="text-accent shrink-0">•</span>
|
||||||
|
<span className="text-base font-medium truncate">{role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={imageRef} className="p-px md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden relative">
|
||||||
|
<motion.div style={{ scale: imageScale }} className="w-full h-full origin-center">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{socialLinks && socialLinks.length > 0 && (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-center gap-3 p-6 xl:p-7 2xl:p-8">
|
||||||
|
{socialLinks.map((link, index) => (
|
||||||
|
<SocialLinkButton key={index} {...link} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutTestimonialParallax;
|
||||||
37
src/components/sections/about/AboutText.tsx
Normal file
37
src/components/sections/about/AboutText.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
|
||||||
|
interface AboutTextProps {
|
||||||
|
title: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const AboutText = ({
|
||||||
|
title,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
}: AboutTextProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="About section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-2 items-center">
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutText;
|
||||||
58
src/components/sections/about/AboutTextSplit.tsx
Normal file
58
src/components/sections/about/AboutTextSplit.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
|
||||||
|
interface AboutTextSplitProps {
|
||||||
|
title: string;
|
||||||
|
descriptions: string[];
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const AboutTextSplit = ({
|
||||||
|
title,
|
||||||
|
descriptions,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
}: AboutTextSplitProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="About section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-20 mx-auto w-content-width">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full md:w-1/2">
|
||||||
|
{descriptions.map((desc, index) => (
|
||||||
|
<TextAnimation
|
||||||
|
key={index}
|
||||||
|
text={desc}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="text-xl md:text-2xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full border-b border-foreground/5" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AboutTextSplit;
|
||||||
91
src/components/sections/blog/BlogArticle.tsx
Normal file
91
src/components/sections/blog/BlogArticle.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type BlogArticleProps = {
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
excerpt?: string;
|
||||||
|
content: string;
|
||||||
|
imageSrc: string;
|
||||||
|
authorName: string;
|
||||||
|
authorImageSrc: string;
|
||||||
|
date: string;
|
||||||
|
readingTime?: string;
|
||||||
|
backButton?: { text: string; href: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogArticle = ({
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
content,
|
||||||
|
imageSrc,
|
||||||
|
authorName,
|
||||||
|
authorImageSrc,
|
||||||
|
date,
|
||||||
|
readingTime,
|
||||||
|
backButton = { text: "Back to Blog", href: "/blog" },
|
||||||
|
}: BlogArticleProps) => {
|
||||||
|
return (
|
||||||
|
<article aria-label="Blog article" className="py-20">
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<div className="flex flex-col gap-3 w-content-width md:max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 mb-1 text-sm text-foreground/75 card rounded w-fit">
|
||||||
|
<a
|
||||||
|
href={backButton.href}
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{backButton.text}
|
||||||
|
</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">{category}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{excerpt && (
|
||||||
|
<p className="text-lg md:text-xl leading-snug text-balance">
|
||||||
|
{excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={authorImageSrc}
|
||||||
|
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-base text-foreground font-semibold leading-snug truncate">{authorName}</span>
|
||||||
|
<span className="text-base text-foreground/75 leading-snug truncate">
|
||||||
|
{date}
|
||||||
|
{readingTime && ` · ${readingTime}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<div className="w-content-width md:max-w-4xl mx-auto aspect-video card rounded overflow-hidden p-2 xl:p-3 2xl:p-4">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<div
|
||||||
|
className="w-content-width md:max-w-4xl mx-auto flex flex-col gap-6 [&>h1]:text-4xl [&>h1]:font-semibold [&>h1]:mt-4 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:mt-4 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:mt-2 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:mt-2 [&>p]:text-base [&>p]:leading-relaxed [&>p]:text-foreground/85 [&_strong]:font-semibold [&_em]:italic [&_u]:underline [&>ul]:flex [&>ul]:flex-col [&>ul]:gap-2 [&>ul]:list-disc [&>ul]:pl-6 [&>ul]:text-base [&>ul]:leading-relaxed [&>ul]:text-foreground/85 [&>ol]:flex [&>ol]:flex-col [&>ol]:gap-2 [&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:text-base [&>ol]:leading-relaxed [&>ol]:text-foreground/85"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogArticle;
|
||||||
160
src/components/sections/blog/BlogMediaCards.tsx
Normal file
160
src/components/sections/blog/BlogMediaCards.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||||
|
|
||||||
|
type BlogItem = {
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
authorName: string;
|
||||||
|
authorImageSrc: string;
|
||||||
|
date: string;
|
||||||
|
imageSrc: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
||||||
|
const handleClick = useButtonClick(item.href, item.onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="card group flex flex-col justify-between gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{item.category}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.authorImageSrc}
|
||||||
|
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
|
||||||
|
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||||
|
<button
|
||||||
|
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlogMediaCardsProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items?: BlogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogMediaCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items: itemsProp,
|
||||||
|
}: BlogMediaCardsProps) => {
|
||||||
|
const { posts, isLoading } = useBlogPosts();
|
||||||
|
const isFromApi = posts.length > 0;
|
||||||
|
const items = isFromApi
|
||||||
|
? posts.map((p) => ({
|
||||||
|
category: p.category,
|
||||||
|
title: p.title,
|
||||||
|
excerpt: p.excerpt,
|
||||||
|
authorName: p.authorName,
|
||||||
|
authorImageSrc: p.authorAvatar,
|
||||||
|
date: p.date,
|
||||||
|
imageSrc: p.imageSrc,
|
||||||
|
onClick: p.onBlogClick,
|
||||||
|
}))
|
||||||
|
: itemsProp;
|
||||||
|
|
||||||
|
if (isLoading && !itemsProp) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Blog section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex justify-center">
|
||||||
|
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Blog section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BlogCardItem key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogMediaCards;
|
||||||
159
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
159
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||||
|
|
||||||
|
type BlogItem = {
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
authorName: string;
|
||||||
|
authorImageSrc: string;
|
||||||
|
date: string;
|
||||||
|
imageSrc: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
||||||
|
const handleClick = useButtonClick(item.href, item.onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-4/3 rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||||
|
<button
|
||||||
|
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm primary-button text-primary-cta-text rounded w-fit">
|
||||||
|
<p>{item.category}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-2 md:mt-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.authorImageSrc}
|
||||||
|
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
|
||||||
|
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlogSimpleCardsProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items?: BlogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogSimpleCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items: itemsProp,
|
||||||
|
}: BlogSimpleCardsProps) => {
|
||||||
|
const { posts, isLoading } = useBlogPosts();
|
||||||
|
const isFromApi = posts.length > 0;
|
||||||
|
const items = isFromApi
|
||||||
|
? posts.map((p) => ({
|
||||||
|
category: p.category,
|
||||||
|
title: p.title,
|
||||||
|
excerpt: p.excerpt,
|
||||||
|
authorName: p.authorName,
|
||||||
|
authorImageSrc: p.authorAvatar,
|
||||||
|
date: p.date,
|
||||||
|
imageSrc: p.imageSrc,
|
||||||
|
onClick: p.onBlogClick,
|
||||||
|
}))
|
||||||
|
: itemsProp;
|
||||||
|
|
||||||
|
if (isLoading && !itemsProp) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Blog section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex justify-center">
|
||||||
|
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Blog section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BlogCardItem key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogSimpleCards;
|
||||||
163
src/components/sections/blog/BlogTagCards.tsx
Normal file
163
src/components/sections/blog/BlogTagCards.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { ArrowUpRight, Loader2 } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import useBlogPosts from "@/hooks/useBlogPosts";
|
||||||
|
|
||||||
|
type BlogItem = {
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
authorName: string;
|
||||||
|
authorImageSrc: string;
|
||||||
|
date: string;
|
||||||
|
tags: string[];
|
||||||
|
imageSrc: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogCardItem = ({ item }: { item: BlogItem }) => {
|
||||||
|
const handleClick = useButtonClick(item.href, item.onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-4/3 rounded overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||||
|
<button
|
||||||
|
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 mb-1">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.authorImageSrc}
|
||||||
|
className="size-6 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground/75 truncate">
|
||||||
|
{item.authorName} • {item.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 md:mt-3">
|
||||||
|
{item.tags.map((tag, index) => (
|
||||||
|
<div key={index} className="px-3 py-1 text-sm primary-button text-primary-cta-text rounded">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlogTagCardsProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items?: BlogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogTagCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items: itemsProp,
|
||||||
|
}: BlogTagCardsProps) => {
|
||||||
|
const { posts, isLoading } = useBlogPosts();
|
||||||
|
const isFromApi = posts.length > 0;
|
||||||
|
const items = isFromApi
|
||||||
|
? posts.map((p) => ({
|
||||||
|
title: p.title,
|
||||||
|
excerpt: p.excerpt,
|
||||||
|
authorName: p.authorName,
|
||||||
|
authorImageSrc: p.authorAvatar,
|
||||||
|
date: p.date,
|
||||||
|
tags: [p.category],
|
||||||
|
imageSrc: p.imageSrc,
|
||||||
|
onClick: p.onBlogClick,
|
||||||
|
}))
|
||||||
|
: itemsProp;
|
||||||
|
|
||||||
|
if (isLoading && !itemsProp) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Blog section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex justify-center">
|
||||||
|
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Blog section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<BlogCardItem key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlogTagCards;
|
||||||
99
src/components/sections/contact/ContactCenter.tsx
Normal file
99
src/components/sections/contact/ContactCenter.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import { sendContactEmail } from "@/lib/api/email";
|
||||||
|
|
||||||
|
type ContactCenterProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputPlaceholder: string;
|
||||||
|
buttonText: string;
|
||||||
|
onSubmit?: (email: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContactCenter = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputPlaceholder,
|
||||||
|
buttonText,
|
||||||
|
onSubmit,
|
||||||
|
}: ContactCenterProps) => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ email });
|
||||||
|
onSubmit?.(email);
|
||||||
|
setEmail("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<ScrollReveal variant="fade" className="flex items-center justify-center py-20 card rounded">
|
||||||
|
<div className="flex flex-col items-center w-full md:w-1/2 gap-2 px-5">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-9/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-9/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 md:mt-3 card rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
|
||||||
|
aria-label="Email address"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? "Sending..." : buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactCenter;
|
||||||
46
src/components/sections/contact/ContactCta.tsx
Normal file
46
src/components/sections/contact/ContactCta.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
const ContactCta = ({
|
||||||
|
tag,
|
||||||
|
text,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
text: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<div className="flex flex-col items-center gap-8 md:gap-10 py-20 px-8 rounded card">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={text}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-5xl 2xl:text-6xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactCta;
|
||||||
108
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
108
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { sendContactEmail } from "@/lib/api/email";
|
||||||
|
|
||||||
|
type ContactSplitEmailProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputPlaceholder: string;
|
||||||
|
buttonText: string;
|
||||||
|
onSubmit?: (email: string) => void;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const ContactSplitEmail = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputPlaceholder,
|
||||||
|
buttonText,
|
||||||
|
onSubmit,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: ContactSplitEmailProps) => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ email });
|
||||||
|
onSubmit?.(email);
|
||||||
|
setEmail("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<ScrollReveal variant="fade-blur" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<div className="flex items-center justify-center py-15 md:py-20 card rounded">
|
||||||
|
<div className="flex flex-col items-center w-full gap-2 px-5">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 md:mt-3 card rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
|
||||||
|
aria-label="Email address"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? "Sending..." : buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-100 md:h-auto md:aspect-square card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactSplitEmail;
|
||||||
155
src/components/sections/contact/ContactSplitForm.tsx
Normal file
155
src/components/sections/contact/ContactSplitForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { sendContactEmail } from "@/lib/api/email";
|
||||||
|
|
||||||
|
type InputField = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
placeholder: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextareaField = {
|
||||||
|
name: string;
|
||||||
|
placeholder: string;
|
||||||
|
rows?: number;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContactSplitFormProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputs: InputField[];
|
||||||
|
textarea?: TextareaField;
|
||||||
|
buttonText: string;
|
||||||
|
onSubmit?: (data: Record<string, string>) => void;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const ContactSplitForm = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputs,
|
||||||
|
textarea,
|
||||||
|
buttonText,
|
||||||
|
onSubmit,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: ContactSplitFormProps) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
initial[input.name] = "";
|
||||||
|
});
|
||||||
|
if (textarea) {
|
||||||
|
initial[textarea.name] = "";
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ formData });
|
||||||
|
onSubmit?.(formData);
|
||||||
|
const reset: Record<string, string> = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
reset[input.name] = "";
|
||||||
|
});
|
||||||
|
if (textarea) {
|
||||||
|
reset[textarea.name] = "";
|
||||||
|
}
|
||||||
|
setFormData(reset);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<div className="p-6 md:p-10 card rounded">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{inputs.map((input) => (
|
||||||
|
<input
|
||||||
|
key={input.name}
|
||||||
|
type={input.type}
|
||||||
|
placeholder={input.placeholder}
|
||||||
|
value={formData[input.name] || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||||
|
required={input.required}
|
||||||
|
aria-label={input.placeholder}
|
||||||
|
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{textarea && (
|
||||||
|
<textarea
|
||||||
|
placeholder={textarea.placeholder}
|
||||||
|
value={formData[textarea.name] || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||||
|
required={textarea.required}
|
||||||
|
rows={textarea.rows || 5}
|
||||||
|
aria-label={textarea.placeholder}
|
||||||
|
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center w-full h-10 px-6 text-sm primary-button text-primary-cta-text rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? "Sending..." : buttonText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-100 md:h-auto card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover rounded" />
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactSplitForm;
|
||||||
202
src/components/sections/contact/ContactSplitFormParallax.tsx
Normal file
202
src/components/sections/contact/ContactSplitFormParallax.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useScroll, useTransform, motion } from "motion/react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { sendContactEmail } from "@/lib/api/email";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type InputField = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
placeholder: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextareaField = {
|
||||||
|
name: string;
|
||||||
|
placeholder: string;
|
||||||
|
rows?: number;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CtaLink = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContactSplitFormParallaxProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputs: InputField[];
|
||||||
|
textarea?: TextareaField;
|
||||||
|
buttonText: string;
|
||||||
|
onSubmit?: (data: Record<string, string>) => void;
|
||||||
|
ctaLinks?: CtaLink[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const CtaLinkButton = ({ icon, label, href, onClick }: CtaLink) => {
|
||||||
|
const Icon = resolveIcon(icon);
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onClick={handleClick}
|
||||||
|
className="flex items-center justify-center gap-2 h-9 px-3 text-sm rounded-full cursor-pointer backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20 text-primary-cta-text font-semibold hover:bg-primary-cta-text/25 transition-all duration-300 ease-out"
|
||||||
|
>
|
||||||
|
<Icon className="size-4" strokeWidth={1.5} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContactSplitFormParallax = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputs,
|
||||||
|
textarea,
|
||||||
|
buttonText,
|
||||||
|
onSubmit,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
ctaLinks,
|
||||||
|
}: ContactSplitFormParallaxProps) => {
|
||||||
|
const imageRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
initial[input.name] = "";
|
||||||
|
});
|
||||||
|
if (textarea) {
|
||||||
|
initial[textarea.name] = "";
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ formData });
|
||||||
|
onSubmit?.(formData);
|
||||||
|
const reset: Record<string, string> = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
reset[input.name] = "";
|
||||||
|
});
|
||||||
|
if (textarea) {
|
||||||
|
reset[textarea.name] = "";
|
||||||
|
}
|
||||||
|
setFormData(reset);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: imageRef,
|
||||||
|
offset: ["start end", "end start"],
|
||||||
|
});
|
||||||
|
const imageScale = useTransform(scrollYProgress, [0, 0.6], [1.3, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<div className="p-6 md:p-10 card rounded">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{inputs.map((input) => (
|
||||||
|
<input
|
||||||
|
key={input.name}
|
||||||
|
type={input.type}
|
||||||
|
placeholder={input.placeholder}
|
||||||
|
value={formData[input.name] || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||||
|
required={input.required}
|
||||||
|
aria-label={input.placeholder}
|
||||||
|
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{textarea && (
|
||||||
|
<textarea
|
||||||
|
placeholder={textarea.placeholder}
|
||||||
|
value={formData[textarea.name] || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||||
|
required={textarea.required}
|
||||||
|
rows={textarea.rows || 5}
|
||||||
|
aria-label={textarea.placeholder}
|
||||||
|
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center w-full h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? "Sending..." : buttonText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={imageRef} className="relative h-100 md:h-auto card rounded overflow-hidden">
|
||||||
|
<motion.div style={{ scale: imageScale }} className="w-full h-full origin-center">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="md:absolute md:inset-0 size-full object-cover" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{ctaLinks && ctaLinks.length > 0 && (
|
||||||
|
<div className="absolute inset-0 flex flex-wrap items-end justify-center gap-3 p-6 xl:p-7 2xl:p-8">
|
||||||
|
{ctaLinks.map((link, index) => (
|
||||||
|
<CtaLinkButton key={index} {...link} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactSplitFormParallax;
|
||||||
155
src/components/sections/contact/ContactSplitFormSharp.tsx
Normal file
155
src/components/sections/contact/ContactSplitFormSharp.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { sendContactEmail } from "@/lib/api/email";
|
||||||
|
|
||||||
|
type InputField = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
placeholder: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextareaField = {
|
||||||
|
name: string;
|
||||||
|
placeholder: string;
|
||||||
|
rows?: number;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContactSplitFormSharpProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputs: InputField[];
|
||||||
|
textarea?: TextareaField;
|
||||||
|
buttonText: string;
|
||||||
|
onSubmit?: (data: Record<string, string>) => void;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const ContactSplitFormSharp = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
inputs,
|
||||||
|
textarea,
|
||||||
|
buttonText,
|
||||||
|
onSubmit,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: ContactSplitFormSharpProps) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
initial[input.name] = "";
|
||||||
|
});
|
||||||
|
if (textarea) {
|
||||||
|
initial[textarea.name] = "";
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await sendContactEmail({ formData });
|
||||||
|
onSubmit?.(formData);
|
||||||
|
const reset: Record<string, string> = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
reset[input.name] = "";
|
||||||
|
});
|
||||||
|
if (textarea) {
|
||||||
|
reset[textarea.name] = "";
|
||||||
|
}
|
||||||
|
setFormData(reset);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Contact section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<ScrollReveal variant="fade-blur" className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<div className="p-6 md:p-10 card rounded-none">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded-none w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{inputs.map((input) => (
|
||||||
|
<input
|
||||||
|
key={input.name}
|
||||||
|
type={input.type}
|
||||||
|
placeholder={input.placeholder}
|
||||||
|
value={formData[input.name] || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||||
|
required={input.required}
|
||||||
|
aria-label={input.placeholder}
|
||||||
|
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded-none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{textarea && (
|
||||||
|
<textarea
|
||||||
|
placeholder={textarea.placeholder}
|
||||||
|
value={formData[textarea.name] || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||||
|
required={textarea.required}
|
||||||
|
rows={textarea.rows || 5}
|
||||||
|
aria-label={textarea.placeholder}
|
||||||
|
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center justify-center w-full h-10 px-6 text-sm primary-button text-primary-cta-text rounded-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? "Sending..." : buttonText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-100 md:h-auto card rounded-none overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover rounded-none" />
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactSplitFormSharp;
|
||||||
107
src/components/sections/faq/FaqSimple.tsx
Normal file
107
src/components/sections/faq/FaqSimple.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FaqItem = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FaqSimple = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FaqItem[];
|
||||||
|
}) => {
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleToggle = (index: number) => {
|
||||||
|
setActiveIndex(activeIndex === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="FAQ section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up" className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleToggle(index)}
|
||||||
|
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
|
||||||
|
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||||
|
<Plus
|
||||||
|
className={cls(
|
||||||
|
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||||
|
activeIndex === index && "rotate-45"
|
||||||
|
)}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{activeIndex === index && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="pt-1 text-base leading-snug">{item.answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqSimple;
|
||||||
122
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
122
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FaqItem = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FaqSplitMediaProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FaqItem[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const FaqSplitMedia = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: FaqSplitMediaProps) => {
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleToggle = (index: number) => {
|
||||||
|
setActiveIndex(activeIndex === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="FAQ section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
|
||||||
|
<ScrollReveal variant="slide-up" className="card relative md:col-span-2 h-80 md:h-auto rounded overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
videoSrc={videoSrc}
|
||||||
|
className="absolute inset-0 size-full object-cover"
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up" delay={0.1} className="md:col-span-3 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleToggle(index)}
|
||||||
|
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
|
||||||
|
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||||
|
<Plus
|
||||||
|
className={cls(
|
||||||
|
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||||
|
activeIndex === index && "rotate-45"
|
||||||
|
)}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{activeIndex === index && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="pt-1 text-base leading-snug">{item.answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqSplitMedia;
|
||||||
109
src/components/sections/faq/FaqTabbedAccordion.tsx
Normal file
109
src/components/sections/faq/FaqTabbedAccordion.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import SelectorButton from "@/components/ui/SelectorButton";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import Transition from "@/components/ui/Transition";
|
||||||
|
import Accordion from "@/components/ui/Accordion";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type FaqItem = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FaqCategory = {
|
||||||
|
name: string;
|
||||||
|
items: FaqItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FaqTabbedAccordionProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
categories: FaqCategory[];
|
||||||
|
cta?: {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonHref: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
}
|
||||||
|
|
||||||
|
const FaqTabbedAccordion = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
categories,
|
||||||
|
cta,
|
||||||
|
}: FaqTabbedAccordionProps) => {
|
||||||
|
const [activeCategory, setActiveCategory] = useState(categories[0]?.name || "");
|
||||||
|
|
||||||
|
const currentItems = categories.find((c) => c.name === activeCategory)?.items || [];
|
||||||
|
const accordionItems = currentItems.map((item) => ({ title: item.question, content: item.answer }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="FAQ section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="card rounded flex flex-col gap-6 md:gap-10 p-6 md:p-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectorButton
|
||||||
|
options={categories.map((c) => ({ value: c.name, label: c.name }))}
|
||||||
|
activeValue={activeCategory}
|
||||||
|
onValueChange={setActiveCategory}
|
||||||
|
className="mt-2 md:mt-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<Transition key={activeCategory} whileInView={false} className="">
|
||||||
|
<Accordion items={accordionItems} />
|
||||||
|
</Transition>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
{cta && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-px bg-foreground/5" />
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-6 justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={cta.imageSrc}
|
||||||
|
videoSrc={cta.videoSrc}
|
||||||
|
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-base text-foreground font-semibold leading-snug truncate">{cta.name}</span>
|
||||||
|
<span className="text-base text-foreground/75 leading-snug truncate">{cta.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button text={cta.buttonText} href={cta.buttonHref} variant="primary" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqTabbedAccordion;
|
||||||
122
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
122
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FaqItem = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FaqTwoColumn = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FaqItem[];
|
||||||
|
}) => {
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleToggle = (index: number) => {
|
||||||
|
setActiveIndex(activeIndex === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const halfLength = Math.ceil(items.length / 2);
|
||||||
|
const firstColumn = items.slice(0, halfLength);
|
||||||
|
const secondColumn = items.slice(halfLength);
|
||||||
|
|
||||||
|
const renderAccordionItem = (item: FaqItem, index: number) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleToggle(index)}
|
||||||
|
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
|
||||||
|
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
|
||||||
|
<Plus
|
||||||
|
className={cls(
|
||||||
|
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||||
|
activeIndex === index && "rotate-45"
|
||||||
|
)}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{activeIndex === index && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="pt-1 text-base leading-snug">{item.answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="FAQ section" className="py-20">
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur" className="card rounded p-6 xl:p-10">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
|
||||||
|
</div>
|
||||||
|
{secondColumn.length > 0 && (
|
||||||
|
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqTwoColumn;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
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 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ScrollReveal
|
||||||
|
variant="fade"
|
||||||
|
key={item.title}
|
||||||
|
className={cls("flex flex-col gap-6 md:gap-10 p-6 md:p-10 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-center w-full md:w-1/2 gap-2">
|
||||||
|
<div className="flex items-center justify-center size-9 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||||
|
<p>{index + 1}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base md:text-lg leading-snug text-balance">{item.description}</p>
|
||||||
|
{(item.primaryButton || item.secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
|
||||||
|
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||||
|
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesAlternatingSplit;
|
||||||
107
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
107
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const ArrowButton = ({ href, onClick }: { href?: string; onClick?: () => void }) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onClick={handleClick}
|
||||||
|
className="group/arrow flex items-center justify-center shrink-0 size-9 primary-button rounded-full cursor-pointer transition-transform duration-300 hover:scale-110"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover/arrow:rotate-45" strokeWidth={2} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesArrowCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesArrowCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesArrowCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
|
||||||
|
<div className="relative aspect-square rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||||
|
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4">
|
||||||
|
<ArrowButton href={item.href} onClick={item.onClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-2 md:mt-3">
|
||||||
|
{item.tags.map((itemTag) => (
|
||||||
|
<div key={itemTag} className="flex items-center h-9 px-3 text-sm card rounded">
|
||||||
|
<p>{itemTag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesArrowCards;
|
||||||
99
src/components/sections/features/FeaturesAttributeCards.tsx
Normal file
99
src/components/sections/features/FeaturesAttributeCards.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type AttributeDetail = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
tags: string;
|
||||||
|
badge?: string | null;
|
||||||
|
details: AttributeDetail[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesAttributeCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesAttributeCards = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesAttributeCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features attribute cards section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="group flex flex-col gap-2 xl:gap-3 2xl:gap-4 h-full rounded-none">
|
||||||
|
<div className="relative aspect-4/3 overflow-hidden rounded-none">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none group-hover:scale-105 transition-transform duration-500" />
|
||||||
|
{item.badge && (
|
||||||
|
<span className="absolute top-2 left-2 xl:top-3 xl:left-3 2xl:top-4 2xl:left-4 px-3 py-1 text-sm text-foreground font-medium card rounded-none">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug">{item.tags}</p>
|
||||||
|
<div className="flex items-center gap-3 text-base mt-0.5">
|
||||||
|
{item.details.map((detail) => {
|
||||||
|
const IconComponent = resolveIcon(detail.icon);
|
||||||
|
return (
|
||||||
|
<span key={detail.label} className="flex items-center gap-1">
|
||||||
|
<IconComponent className="size-[1em]" strokeWidth={1.5} />
|
||||||
|
{detail.label}: {detail.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesAttributeCards;
|
||||||
103
src/components/sections/features/FeaturesBento.tsx
Normal file
103
src/components/sections/features/FeaturesBento.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
|
||||||
|
import TiltedStackCards from "@/components/ui/TiltedStackCards";
|
||||||
|
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
|
||||||
|
import OrbitingIcons from "@/components/ui/OrbitingIcons";
|
||||||
|
import IconTextMarquee from "@/components/ui/IconTextMarquee";
|
||||||
|
import ChatMarquee from "@/components/ui/ChatMarquee";
|
||||||
|
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
|
||||||
|
import MediaStack from "@/components/ui/MediaStack";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type IconInput = string | LucideIcon;
|
||||||
|
|
||||||
|
type FeatureCard = { title: string; description: string } & (
|
||||||
|
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
|
||||||
|
| { bentoComponent: "tilted-stack-cards"; stackCards: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
|
||||||
|
| { bentoComponent: "animated-bar-chart" }
|
||||||
|
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; orbitIcons: IconInput[] }
|
||||||
|
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; marqueeTexts: string[] }
|
||||||
|
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
|
||||||
|
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
|
||||||
|
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getBentoComponent = (feature: FeatureCard) => {
|
||||||
|
switch (feature.bentoComponent) {
|
||||||
|
case "info-card-marquee": return <InfoCardMarquee items={feature.infoCards} />;
|
||||||
|
case "tilted-stack-cards": return <TiltedStackCards items={feature.stackCards} />;
|
||||||
|
case "animated-bar-chart": return <AnimatedBarChart />;
|
||||||
|
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.orbitIcons} />;
|
||||||
|
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.marqueeTexts} />;
|
||||||
|
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
|
||||||
|
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.checklistItems} completedLabel={feature.completedLabel} />;
|
||||||
|
case "media-stack": return <MediaStack items={feature.mediaItems} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeaturesBento = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
features,
|
||||||
|
}: {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
features: FeatureCard[];
|
||||||
|
}) => (
|
||||||
|
<section aria-label="Features bento section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded h-full">
|
||||||
|
<div className="relative h-72 overflow-hidden rounded p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5">{getBentoComponent(feature)}</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
||||||
|
<p className="text-base leading-snug">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FeaturesBento;
|
||||||
83
src/components/sections/features/FeaturesBentoGrid.tsx
Normal file
83
src/components/sections/features/FeaturesBentoGrid.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesBentoGridProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesBentoGrid = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
features,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
}: FeaturesBentoGridProps) => {
|
||||||
|
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up" className="w-content-width mx-auto">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
||||||
|
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesBentoGrid;
|
||||||
112
src/components/sections/features/FeaturesBentoGridCta.tsx
Normal file
112
src/components/sections/features/FeaturesBentoGridCta.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesBentoGridCtaProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||||
|
ctaButton?: {
|
||||||
|
text: string;
|
||||||
|
href: string;
|
||||||
|
avatarSrc?: string;
|
||||||
|
avatarLabel?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesBentoGridCta = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
features,
|
||||||
|
ctaButton,
|
||||||
|
}: FeaturesBentoGridCtaProps) => {
|
||||||
|
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ctaButton && (
|
||||||
|
<ScrollReveal variant="fade-blur" delay={0.2}>
|
||||||
|
<a
|
||||||
|
href={ctaButton.href}
|
||||||
|
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
|
||||||
|
>
|
||||||
|
{ctaButton.avatarSrc && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
|
||||||
|
<img
|
||||||
|
src={ctaButton.avatarSrc}
|
||||||
|
className="w-9 h-9 rounded-full object-cover"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
|
||||||
|
<div className="overflow-hidden flex items-center">
|
||||||
|
<span className="text-primary-cta-text text-sm font-semibold mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
|
||||||
|
<span className="w-9 h-9 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-foreground text-xs font-bold">{ctaButton.avatarLabel || "You"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-base font-semibold whitespace-nowrap">{ctaButton.text}</span>
|
||||||
|
</a>
|
||||||
|
</ScrollReveal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
|
||||||
|
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesBentoGridCta;
|
||||||
86
src/components/sections/features/FeaturesBorderGlow.tsx
Normal file
86
src/components/sections/features/FeaturesBorderGlow.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import BorderGlow from "@/components/ui/BorderGlow";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesBorderGlowProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
features: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesBorderGlow = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
features,
|
||||||
|
}: FeaturesBorderGlowProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features border glow section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||||
|
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{features.map((feature) => {
|
||||||
|
const FeatureIcon = resolveIcon(feature.icon);
|
||||||
|
return (
|
||||||
|
<div key={feature.title} className="relative flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 mt-0.5 h-full min-h-60 md:min-h-70 2xl:min-h-80 card rounded">
|
||||||
|
<div className="flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded-full">
|
||||||
|
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
<BorderGlow />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesBorderGlow;
|
||||||
85
src/components/sections/features/FeaturesComparison.tsx
Normal file
85
src/components/sections/features/FeaturesComparison.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
interface FeaturesComparisonProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
negativeItems: string[];
|
||||||
|
positiveItems: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesComparison = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
negativeItems,
|
||||||
|
positiveItems,
|
||||||
|
}: FeaturesComparisonProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features comparison section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur" className="grid grid-cols-1 md:grid-cols-2 w-content-width md:w-6/10 mx-auto gap-5">
|
||||||
|
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded opacity-50">
|
||||||
|
{negativeItems.map((item) => (
|
||||||
|
<div key={item} className="flex items-start gap-3">
|
||||||
|
<div className="flex items-center justify-center shrink-0 size-6 secondary-button rounded">
|
||||||
|
<X className="size-3 text-foreground" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<span className="text-base">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded">
|
||||||
|
{positiveItems.map((item) => (
|
||||||
|
<div key={item} className="flex items-start gap-3">
|
||||||
|
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||||
|
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<span className="text-base">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesComparison;
|
||||||
94
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
94
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesDetailedCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesDetailedCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesDetailedCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ScrollReveal
|
||||||
|
variant="slide-up"
|
||||||
|
key={item.title}
|
||||||
|
className="flex flex-col md:grid md:grid-cols-2 mx-auto gap-6 md:gap-20 p-6 md:p-10 card rounded group"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-between gap-2">
|
||||||
|
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse md:flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{item.tags.map((itemTag) => (
|
||||||
|
<div key={itemTag} className="px-3 py-1 text-sm card rounded w-fit">
|
||||||
|
<p>{itemTag}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg md:text-xl leading-snug text-balance">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-square md:aspect-5/4 rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesDetailedCards;
|
||||||
100
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
100
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type StepItem = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesDetailedStepsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
steps: StepItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesDetailedSteps = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
steps,
|
||||||
|
}: FeaturesDetailedStepsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features detailed steps section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-content-width mx-auto gap-5">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const stepNumber = String(index + 1).padStart(2, "0");
|
||||||
|
return (
|
||||||
|
<ScrollReveal
|
||||||
|
variant="slide-up"
|
||||||
|
key={step.title}
|
||||||
|
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-6 p-6 md:p-10 card rounded overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-between w-full md:w-1/2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{step.tag}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-7xl md:text-8xl font-semibold leading-[1.15] text-balance">{step.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-2xl md:text-3xl font-semibold leading-snug text-balance">{step.subtitle}</h4>
|
||||||
|
<p className="text-base md:text-lg leading-snug text-balance">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full md:w-35/100 gap-10">
|
||||||
|
<span className="hidden md:block self-end text-7xl md:text-8xl font-semibold text-accent">{stepNumber}</span>
|
||||||
|
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
|
||||||
|
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesDetailedSteps;
|
||||||
117
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
117
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
descriptions: string[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesFlipCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureFlipCard = ({ item }: { item: FeatureItem }) => {
|
||||||
|
const [isFlipped, setIsFlipped] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative w-full cursor-pointer perspective-[3000px]"
|
||||||
|
onClick={() => setIsFlipped(!isFlipped)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-flipped={isFlipped}
|
||||||
|
className="relative w-full h-full transition-transform duration-500 transform-3d data-[flipped=true]:transform-[rotateY(180deg)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||||
|
<Plus className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative overflow-hidden aspect-4/5 rounded">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden transform-[rotateY(180deg)]">
|
||||||
|
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
|
||||||
|
<Plus className="size-4 rotate-45 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-2 p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5 rounded">
|
||||||
|
{item.descriptions.map((desc, index) => (
|
||||||
|
<p key={index} className="text-base md:text-lg leading-snug text-balance">{desc}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeaturesFlipCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesFlipCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<FeatureFlipCard key={item.title} item={item} />
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesFlipCards;
|
||||||
105
src/components/sections/features/FeaturesGridSplit.tsx
Normal file
105
src/components/sections/features/FeaturesGridSplit.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
type BottomFeatureItem = FeatureItem & {
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
avatarsSrc?: string[];
|
||||||
|
avatarsLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesGridSplitProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
topItems: [FeatureItem, FeatureItem];
|
||||||
|
bottomItem: BottomFeatureItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesGridSplit = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
topItems,
|
||||||
|
bottomItem,
|
||||||
|
}: FeaturesGridSplitProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
<ScrollReveal variant="fade-blur" className="grid grid-cols-1 md:grid-cols-2 gap-3 xl:gap-3.5 2xl:gap-4">
|
||||||
|
{topItems.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||||
|
<div className="aspect-square rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<p className="text-lg leading-snug text-balance">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||||
|
<div className="flex flex-col gap-1 justify-center md:w-1/2 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-3xl font-semibold leading-snug text-balance">{bottomItem.title}</h3>
|
||||||
|
<p className="text-lg leading-snug text-balance">{bottomItem.description}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={bottomItem.primaryButton.text} href={bottomItem.primaryButton.href} variant="primary" />
|
||||||
|
{bottomItem.avatarsSrc && bottomItem.avatarsSrc.length > 0 && (
|
||||||
|
<AvatarGroup avatarsSrc={bottomItem.avatarsSrc} size="md" label={bottomItem.avatarsLabel} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-1/2 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo imageSrc={bottomItem.imageSrc} videoSrc={bottomItem.videoSrc} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesGridSplit;
|
||||||
87
src/components/sections/features/FeaturesIconCards.tsx
Normal file
87
src/components/sections/features/FeaturesIconCards.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HoverPattern from "@/components/ui/HoverPattern";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesIconCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
features: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesIconCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
features,
|
||||||
|
}: FeaturesIconCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features icon cards section" className="flex flex-col gap-8 md:gap-10 py-20">
|
||||||
|
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{features.map((feature) => {
|
||||||
|
const FeatureIcon = resolveIcon(feature.icon);
|
||||||
|
return (
|
||||||
|
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||||
|
<HoverPattern className="flex items-center justify-center aspect-square rounded bg-foreground/5 shadow shadow-foreground/5">
|
||||||
|
<div className="relative z-10 flex items-center justify-center size-12 primary-button rounded shadow">
|
||||||
|
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</HoverPattern>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
|
||||||
|
<p className="text-base leading-snug">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesIconCards;
|
||||||
105
src/components/sections/features/FeaturesImageBento.tsx
Normal file
105
src/components/sections/features/FeaturesImageBento.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
href?: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesImageBentoProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesImageBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesImageBentoProps) => {
|
||||||
|
const gridClasses = [
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-4",
|
||||||
|
"md:col-span-3",
|
||||||
|
"md:col-span-3",
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-2",
|
||||||
|
];
|
||||||
|
|
||||||
|
const staggerDelays = [
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0.2,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features image bento section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const content = (
|
||||||
|
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
videoSrc={item.videoSrc}
|
||||||
|
className="rounded group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollReveal key={index} variant="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||||
|
{item.href ? (
|
||||||
|
<a href={item.href} className="block overflow-hidden rounded">
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollReveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesImageBento;
|
||||||
82
src/components/sections/features/FeaturesMarqueeCards.tsx
Normal file
82
src/components/sections/features/FeaturesMarqueeCards.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesMarqueeCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesMarqueeCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesMarqueeCardsProps) => {
|
||||||
|
const duplicated = [...items, ...items, ...items, ...items];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="pt-20 pb-10">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur">
|
||||||
|
<div className="w-content-width mx-auto overflow-hidden mask-fade-x-medium">
|
||||||
|
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||||
|
{duplicated.map((item, i) => (
|
||||||
|
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mb-10 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
videoSrc={item.videoSrc}
|
||||||
|
className="w-full h-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesMarqueeCards;
|
||||||
81
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
81
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesMediaCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesMediaCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesMediaCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<GridOrCarousel >
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
|
||||||
|
<div className="aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesMediaCards;
|
||||||
101
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
101
src/components/sections/features/FeaturesMediaCarousel.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import LoopCarousel from "@/components/ui/LoopCarousel";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
buttonIcon: string | LucideIcon;
|
||||||
|
buttonHref?: string;
|
||||||
|
buttonOnClick?: () => void;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesMediaCarouselProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureMediaCarouselCard = ({ item }: { item: FeatureItem }) => {
|
||||||
|
const handleClick = useButtonClick(item.buttonHref, item.buttonOnClick);
|
||||||
|
const Icon = resolveIcon(item.buttonIcon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden aspect-square md:aspect-3/2 rounded">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||||
|
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex items-center justify-between gap-5 p-4 xl:p-5 2xl:p-6 card rounded backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug truncate">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug truncate">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={item.buttonHref}
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-label="View more"
|
||||||
|
className="flex items-center justify-center shrink-0 size-9 cursor-pointer primary-button rounded-full"
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-primary-cta-text" strokeWidth={2} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeaturesMediaCarousel = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesMediaCarouselProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="w-full py-20">
|
||||||
|
<div className="flex flex-col w-full gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoopCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<FeatureMediaCarouselCard key={item.title} item={item} />
|
||||||
|
))}
|
||||||
|
</LoopCarousel>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesMediaCarousel;
|
||||||
82
src/components/sections/features/FeaturesMediaGrid.tsx
Normal file
82
src/components/sections/features/FeaturesMediaGrid.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesMediaGridProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesMediaGrid = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesMediaGridProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 h-full">
|
||||||
|
<div className="aspect-square overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesMediaGrid;
|
||||||
110
src/components/sections/features/FeaturesResultsComparison.tsx
Normal file
110
src/components/sections/features/FeaturesResultsComparison.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ResultItem = {
|
||||||
|
treatment: string;
|
||||||
|
detail: string;
|
||||||
|
beforeSrc: string;
|
||||||
|
afterSrc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeaturesResultsComparisonProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: ResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageLabel = ({ text, side }: { text: string; side: "left" | "right" }) => (
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"absolute bottom-3 xl:bottom-3.5 2xl:bottom-4 px-3 py-1 w-fit text-sm card rounded",
|
||||||
|
side === "left" ? "left-3 xl:left-3.5 2xl:left-4" : "right-3 xl:right-3.5 2xl:right-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-foreground">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FeaturesResultsComparison = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesResultsComparisonProps) => {
|
||||||
|
const duplicated = [...items, ...items, ...items, ...items];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Results section" className="pt-20 pb-10">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up">
|
||||||
|
<div className="w-content-width mx-auto overflow-hidden mask-fade-x-medium">
|
||||||
|
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||||
|
{duplicated.map((item, i) => (
|
||||||
|
<div key={i} className="shrink-0 w-80 md:w-120 2xl:w-140 mb-10 mr-3 md:mr-5 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
|
||||||
|
<div className="relative flex w-full aspect-3/2">
|
||||||
|
<div className="relative overflow-hidden w-1/2 rounded-l-lg rounded-r-none">
|
||||||
|
<ImageOrVideo imageSrc={item.beforeSrc} className="absolute inset-0 object-cover w-full h-full rounded-l rounded-r-none" />
|
||||||
|
<ImageLabel text="Before" side="left" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute z-10 left-1/2 top-0 bottom-0 w-0.5 bg-background -translate-x-1/2" />
|
||||||
|
<div className="relative overflow-hidden w-1/2 rounded-r-lg rounded-l-none">
|
||||||
|
<ImageOrVideo imageSrc={item.afterSrc} className="absolute inset-0 object-cover w-full h-full rounded-r rounded-l-none" />
|
||||||
|
<ImageLabel text="After" side="right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<h4 className="truncate text-2xl font-semibold leading-snug">
|
||||||
|
{item.treatment}
|
||||||
|
</h4>
|
||||||
|
<p className="text-base leading-snug">
|
||||||
|
{item.detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesResultsComparison;
|
||||||
103
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
103
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Info } from "lucide-react";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesRevealCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesRevealCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesRevealCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||||
|
|
||||||
|
<div className="absolute top-4 left-4 xl:top-6 xl:left-6 2xl:top-8 2xl:left-8 z-20 perspective-[1000px]">
|
||||||
|
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-sm rounded bg-background backface-hidden text-foreground">
|
||||||
|
<p>{index + 1}</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
|
||||||
|
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="absolute inset-x-2 bottom-2 xl:inset-x-3 xl:bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||||
|
<div className="relative flex flex-col gap-0 group-hover:gap-1 xl:group-hover:gap-2 2xl:group-hover:gap-3 p-2 xl:p-3 2xl:p-4 transition-all duration-400">
|
||||||
|
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-white transition-colors duration-400 group-hover:text-foreground">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||||
|
<p className="overflow-hidden text-sm leading-snug text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesRevealCards;
|
||||||
109
src/components/sections/features/FeaturesRevealCardsBento.tsx
Normal file
109
src/components/sections/features/FeaturesRevealCardsBento.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesRevealCardsBentoProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesRevealCardsBento = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoProps) => {
|
||||||
|
const gridClasses = [
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-4",
|
||||||
|
"md:col-span-3",
|
||||||
|
"md:col-span-3",
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-2",
|
||||||
|
];
|
||||||
|
|
||||||
|
const staggerDelays = [
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0.2,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ScrollReveal key={item.title} variant="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||||
|
<a href={item.href} className="block relative overflow-hidden rounded">
|
||||||
|
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
videoSrc={item.videoSrc}
|
||||||
|
className="rounded group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||||
|
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||||
|
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||||
|
<div className="absolute inset-0 -z-10 card rounded translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||||
|
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</ScrollReveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesRevealCardsBento;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesRevealCardsBentoSharpProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
|
||||||
|
const gridClasses = [
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-4",
|
||||||
|
"md:col-span-3",
|
||||||
|
"md:col-span-3",
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-2",
|
||||||
|
"md:col-span-2",
|
||||||
|
];
|
||||||
|
|
||||||
|
const staggerDelays = [
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0,
|
||||||
|
0.1,
|
||||||
|
0.2,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features reveal cards bento section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ScrollReveal key={item.title} variant="fade-blur" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
|
||||||
|
<a href={item.href} className="block relative overflow-hidden rounded-none">
|
||||||
|
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
videoSrc={item.videoSrc}
|
||||||
|
className="rounded-none group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||||
|
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
|
||||||
|
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
|
||||||
|
<div className="absolute inset-0 -z-10 card rounded-none translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
|
||||||
|
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</ScrollReveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesRevealCardsBentoSharp;
|
||||||
233
src/components/sections/features/FeaturesStickyCards.tsx
Normal file
233
src/components/sections/features/FeaturesStickyCards.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { gsap } from "gsap";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
} & (
|
||||||
|
| { leftImageSrc: string; leftVideoSrc?: never }
|
||||||
|
| { leftVideoSrc: string; leftImageSrc?: never }
|
||||||
|
) & (
|
||||||
|
| { rightImageSrc: string; rightVideoSrc?: never }
|
||||||
|
| { rightVideoSrc: string; rightImageSrc?: never }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FeaturesStickyCardsProps {
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardFrame = ({
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
cardRef,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
imageSrc?: string;
|
||||||
|
videoSrc?: string;
|
||||||
|
cardRef: (el: HTMLDivElement | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div ref={cardRef} className={cls("card rounded p-1 overflow-hidden", className)}>
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
videoSrc={videoSrc}
|
||||||
|
className="w-full h-full object-cover rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FeaturesStickyCards = ({
|
||||||
|
items,
|
||||||
|
}: FeaturesStickyCardsProps) => {
|
||||||
|
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const triggerRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
|
||||||
|
const getAnimationConfig = (itemIndex: number, isLeftCard: boolean) => {
|
||||||
|
const isOddItem = itemIndex % 2 === 1;
|
||||||
|
if (isLeftCard) {
|
||||||
|
return {
|
||||||
|
from: { xPercent: -225, rotation: -45 },
|
||||||
|
to: { rotation: isOddItem ? 10 : -10 },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
from: { xPercent: 225, rotation: 45 },
|
||||||
|
to: { rotation: isOddItem ? -10 : 10 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const animateCards = (isMobile: boolean) => {
|
||||||
|
items.forEach((_, itemIndex) => {
|
||||||
|
[0, 1].forEach((cardIndex) => {
|
||||||
|
const refIndex = itemIndex * 2 + cardIndex;
|
||||||
|
const element = isMobile
|
||||||
|
? mobileImageRefs.current[refIndex]
|
||||||
|
: imageRefs.current[refIndex];
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const isLeftCard = cardIndex === 0;
|
||||||
|
|
||||||
|
const fromConfig = isMobile
|
||||||
|
? {
|
||||||
|
xPercent: isLeftCard ? -150 : 150,
|
||||||
|
rotation: isLeftCard ? -25 : 25,
|
||||||
|
}
|
||||||
|
: getAnimationConfig(itemIndex, isLeftCard).from;
|
||||||
|
|
||||||
|
const toConfig = isMobile
|
||||||
|
? {
|
||||||
|
xPercent: 0,
|
||||||
|
rotation: 0,
|
||||||
|
duration: 1,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: element,
|
||||||
|
start: "top 90%",
|
||||||
|
end: "top 50%",
|
||||||
|
scrub: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
xPercent: 0,
|
||||||
|
rotation: getAnimationConfig(itemIndex, isLeftCard).to.rotation,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: triggerRefs.current[itemIndex],
|
||||||
|
start: "top bottom",
|
||||||
|
end: "top top",
|
||||||
|
scrub: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
gsap.fromTo(element, fromConfig, toConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
mm.add("(max-width: 767px)", () => animateCards(true));
|
||||||
|
mm.add("(min-width: 768px)", () => animateCards(false));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mm.revert();
|
||||||
|
imageRefs.current = [];
|
||||||
|
mobileImageRefs.current = [];
|
||||||
|
triggerRefs.current = [];
|
||||||
|
};
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const sectionHeightStyle = { height: `${items.length * 100}vh` };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features sticky cards section" className="py-20 overflow-hidden md:overflow-visible">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="hidden md:flex relative" style={sectionHeightStyle}>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 flex flex-col w-6/10 mx-auto right-0 z-10"
|
||||||
|
style={sectionHeightStyle}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={(el) => { triggerRefs.current[index] = el; }}
|
||||||
|
className="w-full mx-auto h-screen flex justify-center items-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||||
|
<p>{index + 1}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-5xl md:text-6xl font-semibold text-center text-balance">{item.title}</h3>
|
||||||
|
<p className="md:max-w-6/10 text-lg leading-snug text-center">{item.description}</p>
|
||||||
|
{(item.primaryButton || item.secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||||
|
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
|
||||||
|
{items.map((item, itemIndex) => (
|
||||||
|
<div key={itemIndex} className="h-screen w-full absolute top-0 left-0">
|
||||||
|
<div className="w-content-width mx-auto h-full flex flex-row justify-between items-center">
|
||||||
|
<CardFrame
|
||||||
|
imageSrc={item.leftImageSrc}
|
||||||
|
videoSrc={item.leftVideoSrc}
|
||||||
|
cardRef={(el) => {
|
||||||
|
imageRefs.current[itemIndex * 2] = el;
|
||||||
|
}}
|
||||||
|
className="w-25/100 xl:w-27/100 2xl:w-29/100 h-[70vh]"
|
||||||
|
/>
|
||||||
|
<CardFrame
|
||||||
|
imageSrc={item.rightImageSrc}
|
||||||
|
videoSrc={item.rightVideoSrc}
|
||||||
|
cardRef={(el) => {
|
||||||
|
imageRefs.current[itemIndex * 2 + 1] = el;
|
||||||
|
}}
|
||||||
|
className="w-25/100 xl:w-27/100 2xl:w-28/100 h-[70vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:hidden flex flex-col gap-20 w-content-width mx-auto">
|
||||||
|
{items.map((item, itemIndex) => (
|
||||||
|
<div key={itemIndex} className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
|
||||||
|
<p>{itemIndex + 1}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-4xl md:text-5xl font-semibold text-center text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base md:text-lg leading-snug text-center">{item.description}</p>
|
||||||
|
{(item.primaryButton || item.secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||||
|
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-3 justify-center">
|
||||||
|
<CardFrame
|
||||||
|
imageSrc={item.leftImageSrc}
|
||||||
|
videoSrc={item.leftVideoSrc}
|
||||||
|
cardRef={(el) => {
|
||||||
|
mobileImageRefs.current[itemIndex * 2] = el;
|
||||||
|
}}
|
||||||
|
className="w-1/2 aspect-9/16"
|
||||||
|
/>
|
||||||
|
<CardFrame
|
||||||
|
imageSrc={item.rightImageSrc}
|
||||||
|
videoSrc={item.rightVideoSrc}
|
||||||
|
cardRef={(el) => {
|
||||||
|
mobileImageRefs.current[itemIndex * 2 + 1] = el;
|
||||||
|
}}
|
||||||
|
className="w-1/2 aspect-9/16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesStickyCards;
|
||||||
89
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
89
src/components/sections/features/FeaturesTaggedCards.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesTaggedCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: FeatureItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesTaggedCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesTaggedCardsProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Features section" className="py-20">
|
||||||
|
<div className="flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up">
|
||||||
|
<GridOrCarousel>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
|
||||||
|
<div className="relative aspect-square rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||||
|
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4 px-3 py-1 text-sm card rounded w-fit">
|
||||||
|
<p>{item.tag}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-between flex-1 gap-1 p-3 xl:p-3.5 2xl:p-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-snug text-balance">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" className="w-full mt-2 md:mt-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridOrCarousel>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesTaggedCards;
|
||||||
121
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
121
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import Transition from "@/components/ui/Transition";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
interface FeaturesTimelineCardsProps {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton?: { text: string; href: string };
|
||||||
|
secondaryButton?: { text: string; href: string };
|
||||||
|
items: [FeatureItem, FeatureItem, FeatureItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeaturesTimelineCards = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: FeaturesTimelineCardsProps) => {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProgress(0);
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (progress === 100) {
|
||||||
|
setActiveIndex((i) => (i + 1) % items.length);
|
||||||
|
}
|
||||||
|
}, [progress, items.length]);
|
||||||
|
|
||||||
|
const handleCardClick = (index: number) => {
|
||||||
|
if (index !== activeIndex) setActiveIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Features timeline section" className="py-20">
|
||||||
|
<div className="flex flex-col w-content-width mx-auto gap-8 md:gap-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h2"
|
||||||
|
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(primaryButton || secondaryButton) && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||||
|
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition className="flex flex-col gap-5">
|
||||||
|
<div className="relative aspect-square md:aspect-10/4 overflow-hidden card rounded">
|
||||||
|
<Transition key={activeIndex} transitionType="fade" className="absolute inset-px overflow-hidden rounded">
|
||||||
|
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
data-active={index === activeIndex}
|
||||||
|
onClick={() => handleCardClick(index)}
|
||||||
|
className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-center size-8 primary-button rounded">
|
||||||
|
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||||
|
<p className="text-base leading-tight text-balance">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full h-px overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-foreground/20" />
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesTimelineCards;
|
||||||
64
src/components/sections/footer/FooterBasic.tsx
Normal file
64
src/components/sections/footer/FooterBasic.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
title: string;
|
||||||
|
items: FooterLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBasic = ({
|
||||||
|
columns,
|
||||||
|
leftText,
|
||||||
|
rightText,
|
||||||
|
}: {
|
||||||
|
columns: FooterColumn[];
|
||||||
|
leftText: string;
|
||||||
|
rightText: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
aria-label="Site footer"
|
||||||
|
className="w-full pt-20 pb-10 border-t border-foreground/15"
|
||||||
|
>
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="w-full flex flex-wrap justify-between gap-y-10 mb-10">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||||
|
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-foreground/20" />
|
||||||
|
|
||||||
|
<div className="w-full flex items-center justify-between pt-5">
|
||||||
|
<span className="text-sm opacity-50">{leftText}</span>
|
||||||
|
<span className="text-sm opacity-50">{rightText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterBasic;
|
||||||
66
src/components/sections/footer/FooterBrand.tsx
Normal file
66
src/components/sections/footer/FooterBrand.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import AutoFillText from "@/components/ui/AutoFillText";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
items: FooterLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-base">
|
||||||
|
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBrand = ({
|
||||||
|
brand,
|
||||||
|
columns,
|
||||||
|
}: {
|
||||||
|
brand: string;
|
||||||
|
columns: FooterColumn[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
aria-label="Site footer"
|
||||||
|
className="w-full py-15 mt-20 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||||
|
>
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||||
|
<AutoFillText className="font-semibold">{brand}</AutoFillText>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||||
|
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<div key={index} className="flex flex-col items-start gap-3">
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterBrand;
|
||||||
101
src/components/sections/footer/FooterBrandReveal.tsx
Normal file
101
src/components/sections/footer/FooterBrandReveal.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import AutoFillText from "@/components/ui/AutoFillText";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
items: FooterLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-base">
|
||||||
|
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBrandReveal = ({
|
||||||
|
brand,
|
||||||
|
columns,
|
||||||
|
}: {
|
||||||
|
brand: string;
|
||||||
|
columns: FooterColumn[];
|
||||||
|
}) => {
|
||||||
|
const footerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [footerHeight, setFooterHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (footerRef.current) {
|
||||||
|
setFooterHeight(footerRef.current.offsetHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeight();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateHeight);
|
||||||
|
if (footerRef.current) {
|
||||||
|
resizeObserver.observe(footerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative z-0 w-full mt-20"
|
||||||
|
style={{
|
||||||
|
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||||
|
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 w-full"
|
||||||
|
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||||
|
>
|
||||||
|
<footer
|
||||||
|
ref={footerRef}
|
||||||
|
aria-label="Site footer"
|
||||||
|
className="w-full py-15 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||||
|
>
|
||||||
|
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||||
|
<AutoFillText className="font-semibold">{brand}</AutoFillText>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||||
|
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<div key={index} className="flex flex-col items-start gap-3">
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterBrandReveal;
|
||||||
57
src/components/sections/footer/FooterMinimal.tsx
Normal file
57
src/components/sections/footer/FooterMinimal.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import AutoFillText from "@/components/ui/AutoFillText";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type SocialLink = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SocialLinkItem = ({ icon, href, onClick }: SocialLink) => {
|
||||||
|
const Icon = resolveIcon(icon);
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="flex items-center justify-center size-10 rounded-full primary-button text-primary-cta-text cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon className="size-4" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterMinimal = ({
|
||||||
|
brand,
|
||||||
|
copyright,
|
||||||
|
socialLinks,
|
||||||
|
}: {
|
||||||
|
brand: string;
|
||||||
|
copyright: string;
|
||||||
|
socialLinks?: SocialLink[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<footer aria-label="Site footer" className="relative w-full py-20">
|
||||||
|
<div className="flex flex-col w-content-width mx-auto px-10 pb-5 rounded-lg card">
|
||||||
|
<AutoFillText className="font-semibold" paddingY="py-5">{brand}</AutoFillText>
|
||||||
|
|
||||||
|
<div className="h-px w-full mb-5 bg-foreground/50" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 items-center justify-between md:flex-row">
|
||||||
|
<span className="text-base opacity-75">{copyright}</span>
|
||||||
|
{socialLinks && socialLinks.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{socialLinks.map((link, index) => (
|
||||||
|
<SocialLinkItem key={index} icon={link.icon} href={link.href} onClick={link.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterMinimal;
|
||||||
82
src/components/sections/footer/FooterSimple.tsx
Normal file
82
src/components/sections/footer/FooterSimple.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
title: string;
|
||||||
|
items: { label: string; href?: string; onClick?: () => void }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterSimple = ({
|
||||||
|
brand,
|
||||||
|
columns,
|
||||||
|
copyright,
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
brand: string;
|
||||||
|
columns: FooterColumn[];
|
||||||
|
copyright: string;
|
||||||
|
links: FooterLink[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<footer aria-label="Site footer" className="w-full py-15 mt-20 primary-button text-primary-cta-text">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||||
|
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||||
|
|
||||||
|
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||||
|
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||||
|
<span className="text-sm opacity-50">{copyright}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterSimple;
|
||||||
82
src/components/sections/footer/FooterSimpleCard.tsx
Normal file
82
src/components/sections/footer/FooterSimpleCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
title: string;
|
||||||
|
items: FooterLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterSimpleCard = ({
|
||||||
|
brand,
|
||||||
|
columns,
|
||||||
|
copyright,
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
brand: string;
|
||||||
|
columns: FooterColumn[];
|
||||||
|
copyright: string;
|
||||||
|
links: FooterLink[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<footer aria-label="Site footer" className="w-full py-20">
|
||||||
|
<div className="w-content-width mx-auto p-10 rounded-lg card">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||||
|
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||||
|
|
||||||
|
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||||
|
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-8 border-t border-foreground/20">
|
||||||
|
<span className="text-sm opacity-50">{copyright}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterSimpleCard;
|
||||||
95
src/components/sections/footer/FooterSimpleMedia.tsx
Normal file
95
src/components/sections/footer/FooterSimpleMedia.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
title: string;
|
||||||
|
items: FooterLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterSimpleMedia = ({
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
brand,
|
||||||
|
columns,
|
||||||
|
copyright,
|
||||||
|
links,
|
||||||
|
}: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }) & {
|
||||||
|
brand: string;
|
||||||
|
columns: FooterColumn[];
|
||||||
|
copyright: string;
|
||||||
|
links: FooterLink[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<footer aria-label="Site footer" className="relative w-full mt-20 overflow-hidden">
|
||||||
|
<div className="w-full aspect-square md:aspect-16/6 mask-fade-top-long">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
videoSrc={videoSrc}
|
||||||
|
className="w-full h-full object-cover rounded-none!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full py-15 primary-button text-primary-cta-text">
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||||
|
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||||
|
|
||||||
|
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||||
|
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||||
|
<span className="text-sm opacity-50">{copyright}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterSimpleMedia;
|
||||||
120
src/components/sections/footer/FooterSimpleReveal.tsx
Normal file
120
src/components/sections/footer/FooterSimpleReveal.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||||
|
|
||||||
|
type FooterColumn = {
|
||||||
|
title: string;
|
||||||
|
items: { label: string; href?: string; onClick?: () => void }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FooterLink = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||||
|
const handleClick = useButtonClick(href, onClick);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FooterSimpleReveal = ({
|
||||||
|
brand,
|
||||||
|
columns,
|
||||||
|
copyright,
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
brand: string;
|
||||||
|
columns: FooterColumn[];
|
||||||
|
copyright: string;
|
||||||
|
links: FooterLink[];
|
||||||
|
}) => {
|
||||||
|
const footerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [footerHeight, setFooterHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (footerRef.current) {
|
||||||
|
setFooterHeight(footerRef.current.offsetHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeight();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateHeight);
|
||||||
|
if (footerRef.current) {
|
||||||
|
resizeObserver.observe(footerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative z-0 w-full mt-20"
|
||||||
|
style={{
|
||||||
|
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||||
|
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 w-full"
|
||||||
|
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||||
|
>
|
||||||
|
<footer
|
||||||
|
ref={footerRef}
|
||||||
|
aria-label="Site footer"
|
||||||
|
className="w-full py-15 primary-button text-primary-cta-text"
|
||||||
|
>
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||||
|
<h2 className="text-4xl font-semibold">{brand}</h2>
|
||||||
|
|
||||||
|
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||||
|
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
|
||||||
|
{column.items.map((item) => (
|
||||||
|
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||||
|
<span className="text-sm opacity-50">{copyright}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FooterSimpleReveal;
|
||||||
72
src/components/sections/hero/HeroBillboard.tsx
Normal file
72
src/components/sections/hero/HeroBillboard.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||||
|
|
||||||
|
type HeroBillboardProps = {
|
||||||
|
tag?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
avatarsSrc?: string[];
|
||||||
|
avatarsLabel?: string;
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const HeroBillboard = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
avatarsSrc,
|
||||||
|
avatarsLabel,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: HeroBillboardProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div className="flex flex-col gap-12 md:gap-15 w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
{avatarsSrc && avatarsSrc.length > 0 ? (
|
||||||
|
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} className="mb-1" />
|
||||||
|
) : tag ? (
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h1"
|
||||||
|
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboard;
|
||||||
52
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
52
src/components/sections/hero/HeroBillboardBrand.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import AutoFillText from "@/components/ui/AutoFillText";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type HeroBillboardBrandProps = {
|
||||||
|
brand: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const HeroBillboardBrand = ({
|
||||||
|
brand,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: HeroBillboardBrandProps) => {
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div className="flex flex-col gap-10 md:gap-12 w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-end gap-5">
|
||||||
|
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="w-full md:w-1/2 text-lg md:text-2xl leading-snug text-balance text-right"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="fade" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardBrand;
|
||||||
75
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
75
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type HeroBillboardCarouselProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeroBillboardCarousel = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
items,
|
||||||
|
}: HeroBillboardCarouselProps) => {
|
||||||
|
const duplicated = [...items, ...items, ...items, ...items];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Hero section"
|
||||||
|
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||||
|
>
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h1"
|
||||||
|
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||||
|
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||||
|
{duplicated.map((item, i) => (
|
||||||
|
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={item.imageSrc}
|
||||||
|
videoSrc={item.videoSrc}
|
||||||
|
className="w-full h-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardCarousel;
|
||||||
139
src/components/sections/hero/HeroBillboardCreator.tsx
Normal file
139
src/components/sections/hero/HeroBillboardCreator.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type CreatorVideo = {
|
||||||
|
videoSrc: string;
|
||||||
|
name: string;
|
||||||
|
followers: string;
|
||||||
|
imageSrc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeroBillboardCreatorProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
titleHighlight?: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
note: string;
|
||||||
|
videos: CreatorVideo[];
|
||||||
|
badgeText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeroBillboardCreator = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
titleHighlight,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
note,
|
||||||
|
videos,
|
||||||
|
badgeText,
|
||||||
|
}: HeroBillboardCreatorProps) => {
|
||||||
|
const duplicated = [...videos, ...videos, ...videos, ...videos];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Hero section"
|
||||||
|
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
|
||||||
|
>
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
|
||||||
|
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
className="md:max-w-8/10 text-7xl 2xl:text-8xl font-semibold leading-[1.15] text-balance"
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-20%" }}
|
||||||
|
transition={{ staggerChildren: 0.04 }}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent"
|
||||||
|
variants={{ hidden: { opacity: 0, y: "50%" }, visible: { opacity: 1, y: 0 } }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
|
>
|
||||||
|
{title}{" "}
|
||||||
|
{titleHighlight && (
|
||||||
|
<span className="italic" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||||
|
{titleHighlight}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
||||||
|
<Check className="size-1/2 text-primary-cta-text" />
|
||||||
|
</div>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
|
||||||
|
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
|
||||||
|
{duplicated.map((video, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative shrink-0 mr-3 xl:mr-4 2xl:mr-5 w-60 md:w-75 2xl:w-80 aspect-4/5 overflow-hidden rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="absolute z-10 top-3 left-3 xl:top-4 xl:left-4 2xl:top-5 2xl:left-5 px-2 py-1 xl:px-2.5 xl:py-1.5 2xl:px-3 2xl:py-2 text-xs font-medium rounded-sm border border-background/30 bg-background/50 backdrop-blur-md">
|
||||||
|
{badgeText}
|
||||||
|
</div>
|
||||||
|
<ImageOrVideo
|
||||||
|
videoSrc={video.videoSrc}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -inset-x-px -bottom-px h-1/3 bg-background-accent/50 backdrop-blur-xl"
|
||||||
|
style={{ maskImage: "linear-gradient(to bottom, transparent, black 60%)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="absolute flex items-center inset-x-3 bottom-3 xl:inset-x-4 xl:bottom-4 2xl:inset-x-5 2xl:bottom-5 gap-2 xl:gap-2.5 2xl:gap-3">
|
||||||
|
<ImageOrVideo
|
||||||
|
imageSrc={video.imageSrc}
|
||||||
|
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="flex items-center gap-1 text-base text-background font-semibold leading-snug truncate">
|
||||||
|
{video.name}
|
||||||
|
<img src="https://storage.googleapis.com/webild/default/templates/ai-ugc/verified-badge.webp" alt="Verified" className="shrink-0 h-[calc(var(--text-base)*1.25)] w-auto" />
|
||||||
|
</span>
|
||||||
|
<span className="text-base text-background/75 leading-snug truncate">{video.followers}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardCreator;
|
||||||
105
src/components/sections/hero/HeroBillboardFeatures.tsx
Normal file
105
src/components/sections/hero/HeroBillboardFeatures.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
import ActiveBadge from "@/components/ui/ActiveBadge";
|
||||||
|
import { resolveIcon } from "@/utils/resolve-icon";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
icon: string | LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeroBillboardFeaturesProps = {
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
features: FeatureItem[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const INTERVAL = 5000;
|
||||||
|
|
||||||
|
const HeroBillboardFeatures = ({
|
||||||
|
badge,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
features,
|
||||||
|
}: HeroBillboardFeaturesProps) => {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (features.length <= 1) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentIndex((prev) => (prev + 1) % features.length);
|
||||||
|
}, INTERVAL);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [features.length]);
|
||||||
|
|
||||||
|
const feature = features[currentIndex];
|
||||||
|
const FeatureIcon = resolveIcon(feature.icon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div className="flex flex-col gap-12 w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<ActiveBadge text={badge} className="mb-1" />
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h1"
|
||||||
|
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentIndex}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="absolute top-4 right-4 xl:top-6 xl:right-6 2xl:top-8 2xl:right-8 max-w-xs p-2 xl:p-3 2xl:p-4 card rounded flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<FeatureIcon className="size-5 text-accent mb-0.5" strokeWidth={1.5} />
|
||||||
|
<p className="text-base font-medium leading-snug">{feature.title}</p>
|
||||||
|
<p className="text-sm text-foreground/75 leading-snug">{feature.description}</p>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardFeatures;
|
||||||
180
src/components/sections/hero/HeroBillboardFloatingCards.tsx
Normal file
180
src/components/sections/hero/HeroBillboardFloatingCards.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useScroll, useTransform, motion } from "motion/react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||||
|
|
||||||
|
type FloatingCardPosition = "top-left" | "top-right" | "middle-left" | "middle-right";
|
||||||
|
|
||||||
|
type HeroBillboardFloatingCardsProps = {
|
||||||
|
avatarsSrc: string[];
|
||||||
|
avatarsLabel: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
note?: string;
|
||||||
|
floatingCardsSrc: [string, string, string, string];
|
||||||
|
logosSrc?: string[];
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const POSITIONS: FloatingCardPosition[] = ["top-left", "top-right", "middle-left", "middle-right"];
|
||||||
|
|
||||||
|
const FLOATING_CARD_CONFIG: Record<FloatingCardPosition, {
|
||||||
|
position: string;
|
||||||
|
rotation: string;
|
||||||
|
size: string;
|
||||||
|
animation: { duration: number; delay: number; yOffset: number; entryDelay: number };
|
||||||
|
}> = {
|
||||||
|
"top-left": {
|
||||||
|
position: "top-8 left-0",
|
||||||
|
rotation: "-rotate-8",
|
||||||
|
size: "size-20 xl:size-22 2xl:size-24",
|
||||||
|
animation: { duration: 4, delay: 0, yOffset: -8, entryDelay: 0.3 },
|
||||||
|
},
|
||||||
|
"top-right": {
|
||||||
|
position: "top-4 right-4",
|
||||||
|
rotation: "rotate-10",
|
||||||
|
size: "size-18 xl:size-20 2xl:size-22",
|
||||||
|
animation: { duration: 5, delay: 1, yOffset: -10, entryDelay: 0.5 },
|
||||||
|
},
|
||||||
|
"middle-left": {
|
||||||
|
position: "top-1/2 left-2",
|
||||||
|
rotation: "rotate-6",
|
||||||
|
size: "size-18 xl:size-20 2xl:size-22",
|
||||||
|
animation: { duration: 4.5, delay: 0.5, yOffset: -9, entryDelay: 0.7 },
|
||||||
|
},
|
||||||
|
"middle-right": {
|
||||||
|
position: "top-1/2 right-0",
|
||||||
|
rotation: "-rotate-6",
|
||||||
|
size: "size-20 xl:size-22 2xl:size-24",
|
||||||
|
animation: { duration: 3.8, delay: 1.5, yOffset: -8, entryDelay: 0.9 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeroBillboardFloatingCards = ({
|
||||||
|
avatarsSrc,
|
||||||
|
avatarsLabel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
note,
|
||||||
|
floatingCardsSrc,
|
||||||
|
logosSrc,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: HeroBillboardFloatingCardsProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { scrollYProgress } = useScroll({ target: containerRef });
|
||||||
|
|
||||||
|
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
||||||
|
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="relative">
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div ref={containerRef} className="pt-25 pb-20 md:pt-30 perspective-distant">
|
||||||
|
<div className="relative w-content-width mx-auto">
|
||||||
|
{POSITIONS.map((position, index) => {
|
||||||
|
const config = FLOATING_CARD_CONFIG[position];
|
||||||
|
const src = floatingCardsSrc[index];
|
||||||
|
if (!src) return null;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className={cls("absolute z-10 hidden md:block", config.position)}
|
||||||
|
animate={{ y: [0, config.animation.yOffset, 0] }}
|
||||||
|
transition={{ duration: config.animation.duration, repeat: Infinity, ease: "easeInOut", delay: config.animation.delay }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cls("p-2 card rounded-2xl overflow-hidden", config.size, config.rotation)}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: config.animation.entryDelay }}
|
||||||
|
>
|
||||||
|
<img src={src} alt="" className="w-full h-full object-contain rounded-xl" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-3 md:max-w-8/10 mx-auto text-center">
|
||||||
|
<div className="p-0.5 pr-3 mb-1 card rounded-full">
|
||||||
|
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h1"
|
||||||
|
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="slide-up"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{note && (
|
||||||
|
<motion.div
|
||||||
|
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center justify-center size-4 primary-button rounded-full">
|
||||||
|
<Check className="size-1/2 text-primary-cta-text" />
|
||||||
|
</div>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
style={{ rotateX: rotate, scale }}
|
||||||
|
className="w-content-width mx-auto mt-5 2xl:mt-2 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{logosSrc && logosSrc.length > 0 && (
|
||||||
|
<ScrollReveal variant="slide-up" className="w-content-width mx-auto mt-2 xl:mt-4 2xl:mt-6 overflow-hidden mask-fade-x">
|
||||||
|
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
|
||||||
|
{[...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc].map((logo, index) => (
|
||||||
|
<div key={index} className="shrink-0 mx-1 xl:mx-2 2xl:mx-3 p-3 rounded card">
|
||||||
|
<img src={logo} alt="" className="h-8 w-auto object-contain rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardFloatingCards;
|
||||||
82
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
82
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useScroll, useTransform, motion } from "motion/react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||||
|
import TextAnimation from "@/components/ui/TextAnimation";
|
||||||
|
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||||
|
|
||||||
|
type HeroBillboardScrollProps = {
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButton: { text: string; href: string };
|
||||||
|
secondaryButton: { text: string; href: string };
|
||||||
|
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||||
|
|
||||||
|
const HeroBillboardScroll = ({
|
||||||
|
tag,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
imageSrc,
|
||||||
|
videoSrc,
|
||||||
|
}: HeroBillboardScrollProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { scrollYProgress } = useScroll({ target: containerRef });
|
||||||
|
|
||||||
|
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
|
||||||
|
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Hero section" className="relative">
|
||||||
|
<HeroBackgroundSlot />
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="pt-25 pb-20 md:pt-30 perspective-distant"
|
||||||
|
>
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
|
||||||
|
<p>{tag}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={title}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={true}
|
||||||
|
tag="h1"
|
||||||
|
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextAnimation
|
||||||
|
text={description}
|
||||||
|
variant="fade-blur"
|
||||||
|
gradientText={false}
|
||||||
|
tag="p"
|
||||||
|
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
|
||||||
|
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||||
|
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
style={{ rotateX: rotate, scale }}
|
||||||
|
className="w-content-width mx-auto mt-12 2xl:mt-8 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
|
||||||
|
>
|
||||||
|
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroBillboardScroll;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user