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=0ae610ce-f71e-44aa-9b81-f2bb18ae54b7
|
||||
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=
|
||||
VITE_PROJECT_ID=
|
||||
46
.gitea/workflows/build.yml
Normal file
46
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Code Check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: git clone --depth 1 --branch ${{ gitea.ref_name }} ${{ gitea.server_url }}/${{ gitea.repository }}.git . || exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
if [ -f "vite.config.ts" ]; then
|
||||
if [ -d "/var/node_modules_cache_v4/node_modules" ]; then
|
||||
ln -s /var/node_modules_cache_v4/node_modules ./node_modules
|
||||
elif [ -f "pnpm-lock.yaml" ]; then
|
||||
npm install -g pnpm --silent
|
||||
pnpm install --frozen-lockfile
|
||||
else
|
||||
npm ci --prefer-offline --no-audit
|
||||
fi
|
||||
elif [ -d "/var/node_modules_cache/node_modules" ]; then
|
||||
ln -s /var/node_modules_cache/node_modules ./node_modules
|
||||
else
|
||||
npm ci --prefer-offline --no-audit
|
||||
fi
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: TypeScript check
|
||||
run: npm run typecheck 2>&1 | tee build.log
|
||||
timeout-minutes: 3
|
||||
|
||||
- name: ESLint check
|
||||
run: npm run lint 2>&1 | tee -a build.log
|
||||
timeout-minutes: 3
|
||||
|
||||
- name: Upload build log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build-log
|
||||
path: build.log
|
||||
retention-days: 1
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
218
STRUCTURE.md
Normal file
218
STRUCTURE.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# AI-Friendly Component Library
|
||||
|
||||
## The Problem
|
||||
|
||||
Current architecture is optimized for human developers (DRY, reusable, flexible) but this makes AI editing fail. AI can't trace through ThemeProvider indirection, 40+ prop interfaces, nested component layers, and custom Tailwind scales. It hallucinates because it can't see the actual output.
|
||||
|
||||
## The Solution
|
||||
|
||||
Shift from human-optimized to AI-optimized code:
|
||||
- **Explicit over implicit** - All styles visible in file, no ThemeProvider magic
|
||||
- **Flat over nested** - Direct components, no wrapper layers to trace through
|
||||
- **Concrete over abstract** - Actual Tailwind classes, not prop names that map to hidden styles
|
||||
- **Standard over custom** - Default Tailwind values AI already knows
|
||||
- **Simple over flexible** - One way to do things, not 11 variants
|
||||
|
||||
Components become templates that AI reads, understands, and edits directly.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend-Driven Sections
|
||||
|
||||
Generated website repository only contains what is used, plus general UI components. Section components are stored on the backend and injected when needed, not bundled in the boilerplate.
|
||||
|
||||
**Alternative:** If backend approach takes too long, keep all sections in the boilerplate but with the new simplified structure defined below.
|
||||
|
||||
---
|
||||
|
||||
## 2. Props & Structure
|
||||
|
||||
Remove all styling/className props. AI edits classes directly in the component file.
|
||||
|
||||
**Option A - Minimal props:** Keep text, links, assets, arrays, icons as props. Backend passes content, AI edits props in page file.
|
||||
|
||||
**Option B - No props:** All content hardcoded in section file. Backend injects entire file with content baked in. AI edits section file directly. Maximum explicitness.
|
||||
|
||||
**Flat structure:** Sections use direct components (TextAnimation, Button, MediaContent) not wrapper components like TextBox that nest other components inside.
|
||||
|
||||
---
|
||||
|
||||
## 3. Framer Motion for Element Animations
|
||||
|
||||
Replace GSAP (ScrollTrigger, `gsap.context()`, `ctx.revert()`) with Framer Motion `motion` divs for cards, buttons, tags, and other elements. Use `whileInView` for scroll-triggered animations.
|
||||
|
||||
---
|
||||
|
||||
## 4. Simplified Tailwind & Single CSS File
|
||||
|
||||
Remove all custom Tailwind values (spacing, widths, gaps). Use default Tailwind classes.
|
||||
|
||||
**Keep:**
|
||||
- Content width approach (for page width and carousels)
|
||||
- Fluid font sizes (AI picks bad font sizes otherwise)
|
||||
|
||||
**One globals.css file with:**
|
||||
- Color variables in `:root`
|
||||
- `--radius` variable, applied globally via `@layer base`
|
||||
- Card styles
|
||||
- Button styles
|
||||
- Utility classes (masks, animations) - consolidated, fewer variants
|
||||
- Base styling (body defaults, scrollbar, heading font-family)
|
||||
|
||||
AI picks from a reference list of available styles and writes them directly to globals.css.
|
||||
|
||||
---
|
||||
|
||||
## 5. Remove ThemeProvider
|
||||
|
||||
Delete the entire ThemeProvider system. No dynamic style injection.
|
||||
|
||||
**Card/button styles:** AI chooses on backend, adds one card style, one primary button style, one secondary button style to globals.css.
|
||||
|
||||
**Text animations:** Remove TextBox component. Use simple `TextAnimation` component in section files with `text` and `animationType` props.
|
||||
|
||||
**Button:** Single `components/ui/Button.tsx` with `text`, `href`, and `className` (e.g. `primary-button` defined in globals.css).
|
||||
|
||||
**Content width:** Fixed value in globals.css.
|
||||
|
||||
**Text sizing:** Fixed values in globals.css. Future: AI picks from 3 presets on backend and adds to globals.css.
|
||||
|
||||
**Background components:** Remove all (aurora, grid, floatingGradient, etc.).
|
||||
|
||||
**Fonts:** Keep current logic.
|
||||
|
||||
---
|
||||
|
||||
## 6. New Folder Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Pages
|
||||
├── components/
|
||||
│ ├── ui/ # Atomic UI components
|
||||
│ └── [sections] # Section components flat, not nested (injected from backend)
|
||||
├── hooks/ # Common hooks
|
||||
├── lib/ # Utilities
|
||||
└── styles/
|
||||
└── globals.css # Single CSS file
|
||||
```
|
||||
|
||||
Remove: `providers/themeProvider/`, multiple CSS files, `button/` variants folder, `text/` folder, `background/` folder.
|
||||
|
||||
---
|
||||
|
||||
## 7. Atomic UI Components
|
||||
|
||||
Add simple, single-purpose components to `components/ui/`:
|
||||
|
||||
Button, TextAnimation, MediaContent, Toggle, Dropdown, Accordion, Avatar, Badge, Breadcrumb, Calendar, Chart, Checkbox, Form, Input, Label, Tooltip
|
||||
|
||||
Expand as needed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Simplify Complex Components
|
||||
|
||||
**Navbar:** Separate navbar components instead of one with variants. AI chooses which one. Reduce to 4 navbars for now.
|
||||
|
||||
**CardStack → GridLayout:** Replace complex CardStack (mode, gridVariant, carouselThreshold, ~40 props) with simple wrapper. Children as items. Single prop: items per row (3 or 4). Under = grid, over = carousel. Carousel mode keeps controls (prev/next, progress bar). No title/description/animation props - sections handle all that directly. Remove timelines, grid configs, auto-carousel variants for now, refactor later.
|
||||
|
||||
**TextBox:** Remove completely. Use TextAnimation and Button directly in sections.
|
||||
|
||||
---
|
||||
|
||||
## 9. Consistent File Patterns
|
||||
|
||||
Every section file follows the EXACT same structure. AI sees one section, knows how all work.
|
||||
|
||||
**Template:**
|
||||
```
|
||||
1. IMPORTS (same order: react, framer-motion, ui components, icons)
|
||||
2. CONTENT (variables at top if Option B)
|
||||
3. COMPONENT (predictable JSX order)
|
||||
4. EXPORT (always at bottom)
|
||||
```
|
||||
|
||||
**JSX order:** tag → title → description → buttons → media (always same sequence)
|
||||
|
||||
---
|
||||
|
||||
## 10. Short Files, No Sub-components
|
||||
|
||||
**Line limits:**
|
||||
- Sections: under 100 lines
|
||||
- UI components: under 50 lines
|
||||
|
||||
**Key rule:** Don't split into sub-components to achieve this. Sub-components add nesting/indirection. Instead, simplify the section itself.
|
||||
|
||||
If a section is too long, it's too complex. Simplify the design, don't extract parts.
|
||||
|
||||
---
|
||||
|
||||
## 11. Content at Top
|
||||
|
||||
If using Option B (no props), put all editable content as variables at the very top of the file.
|
||||
|
||||
AI immediately knows: "edit content? check the top."
|
||||
|
||||
---
|
||||
|
||||
## 12. Remove Complex Components
|
||||
|
||||
Remove overly complex components for now. Add back simplified versions later if needed.
|
||||
|
||||
---
|
||||
|
||||
## 13. CSS Class Organization
|
||||
|
||||
**Consistent class order:** layout → spacing → sizing → typography → colors → effects
|
||||
|
||||
**Keep className short.** Too many classes = section too complex. Use global CSS (`.card`, `.btn-primary`) for repeated patterns.
|
||||
|
||||
**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 |
|
||||
|
||||
---
|
||||
257
TEMPLATES.md
Normal file
257
TEMPLATES.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Templates
|
||||
|
||||
Templates are complete website starting points. Props for content, default values for demo content, inline sections for freedom.
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup Flow
|
||||
|
||||
### 1. Define Sections
|
||||
List the sections for the template:
|
||||
```
|
||||
NavbarFloating, HeroBillboardBrand, AboutTextSplit, ProductMediaCards,
|
||||
FeaturesBento, TestimonialTrustCard, FaqSplitMedia, ContactCenter, FooterSimpleCard
|
||||
```
|
||||
|
||||
### 2. Create Files
|
||||
```
|
||||
src/templates/[name]/
|
||||
├── page.tsx
|
||||
└── theme.css
|
||||
```
|
||||
|
||||
### 3. Build page.tsx
|
||||
|
||||
**Imports** - UI components only, never sections:
|
||||
```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 AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { Star, ArrowUpRight } from "lucide-react";
|
||||
import "./theme.css";
|
||||
```
|
||||
|
||||
**Types** - One type per section:
|
||||
```tsx
|
||||
type TemplateProps = {
|
||||
hero: { brand: string; description: string; /* ... */ };
|
||||
about: { title: string; description: string; /* ... */ };
|
||||
};
|
||||
```
|
||||
|
||||
**Default Props** - Demo content:
|
||||
```tsx
|
||||
const defaultProps: TemplateProps = {
|
||||
hero: { brand: "Brand", description: "Description text" },
|
||||
about: { title: "About Us", description: "..." },
|
||||
};
|
||||
```
|
||||
|
||||
**Sections** - Copy code from base section, add marker:
|
||||
```tsx
|
||||
{/* === HERO (base: HeroBillboardBrand) === */}
|
||||
<section>
|
||||
{/* Inline code from HeroBillboardBrand.tsx */}
|
||||
</section>
|
||||
```
|
||||
|
||||
### 4. Build theme.css
|
||||
|
||||
Copy full structure from existing template, change only:
|
||||
- Color values in `:root`
|
||||
- Card style (`.card`)
|
||||
- Button styles (`.primary-button`, `.secondary-button`)
|
||||
|
||||
Required structure:
|
||||
```css
|
||||
/* [Name] - [Theme Description] */
|
||||
@import "tailwindcss";
|
||||
@import "../../styles/masks.css";
|
||||
@import "../../styles/animations.css";
|
||||
|
||||
:root {
|
||||
/* @colorThemes/[lightTheme|darkTheme]/[themeName] */
|
||||
--background: #...;
|
||||
--card: #...;
|
||||
--foreground: #...;
|
||||
--primary-cta: #...;
|
||||
--primary-cta-text: #...;
|
||||
--secondary-cta: #...;
|
||||
--secondary-cta-text: #...;
|
||||
--accent: #...;
|
||||
--background-accent: #...;
|
||||
|
||||
/* @layout/border-radius/rounded */
|
||||
--radius: 1.5rem;
|
||||
|
||||
/* @layout/content-width/medium */
|
||||
--width-content-width: clamp(40rem, 72.5vw, 100rem);
|
||||
|
||||
/* ... carousel, typography variables ... */
|
||||
}
|
||||
|
||||
/* Mobile typography */
|
||||
@media (max-width: 768px) { :root { /* ... */ } }
|
||||
|
||||
@theme inline { /* Tailwind mappings */ }
|
||||
|
||||
/* Base styles: *, html, body, h1-h6 */
|
||||
|
||||
/* WEBILD_CARD_STYLE */
|
||||
/* @cards/[style-name] */
|
||||
.card { /* ... */ }
|
||||
|
||||
/* WEBILD_PRIMARY_BUTTON */
|
||||
/* @buttons/primary-button-styles/[style-name] */
|
||||
.primary-button { /* ... */ }
|
||||
|
||||
/* WEBILD_SECONDARY_BUTTON */
|
||||
/* @buttons/secondary-button-styles/[style-name] */
|
||||
.secondary-button { /* ... */ }
|
||||
```
|
||||
|
||||
### 5. Add Route
|
||||
In `src/pages/components/templates/`:
|
||||
- Create `[Name]TemplatePage.tsx`
|
||||
- Add to `TemplateListPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/templates/
|
||||
├── saas/
|
||||
│ ├── page.tsx # Props + defaults + inline sections
|
||||
│ └── theme.css # Colors, buttons, cards
|
||||
└── restaurant/
|
||||
├── page.tsx
|
||||
└── theme.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Pattern
|
||||
|
||||
```tsx
|
||||
// UI components (always imported)
|
||||
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 "./theme.css";
|
||||
|
||||
// Types
|
||||
type SaasTemplateProps = {
|
||||
hero: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
features: { /* ... */ };
|
||||
};
|
||||
|
||||
// Default content (demo data)
|
||||
const defaultProps: SaasTemplateProps = {
|
||||
hero: {
|
||||
tag: "SaaS Platform",
|
||||
title: "Build Modern Web Experiences",
|
||||
description: "Create stunning websites...",
|
||||
primaryButton: { text: "Get Started", href: "#contact" },
|
||||
secondaryButton: { text: "Learn More", href: "#features" },
|
||||
imageSrc: "https://...",
|
||||
},
|
||||
features: { /* ... */ },
|
||||
};
|
||||
|
||||
// Template component
|
||||
const SaasTemplate = ({
|
||||
hero = defaultProps.hero,
|
||||
features = defaultProps.features,
|
||||
}: Partial<SaasTemplateProps>) => {
|
||||
return (
|
||||
<>
|
||||
{/* === HERO (base: HeroSplit) === */}
|
||||
<section aria-label="Hero section" className="...">
|
||||
<TextAnimation text={hero.title} /* ... */ />
|
||||
<Button text={hero.primaryButton.text} href={hero.primaryButton.href} />
|
||||
<ImageOrVideo imageSrc={hero.imageSrc} videoSrc={hero.videoSrc} />
|
||||
</section>
|
||||
|
||||
{/* === FEATURES (base: FeaturesMediaCards) === */}
|
||||
<section aria-label="Features section">
|
||||
{/* Inline code using features prop */}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaasTemplate;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Markers
|
||||
|
||||
Each section has a comment marking its base section:
|
||||
|
||||
```tsx
|
||||
{/* === HERO (base: HeroSplit) === */}
|
||||
```
|
||||
|
||||
Find all templates using a section: `grep -r "base: HeroSplit" src/templates/`
|
||||
|
||||
---
|
||||
|
||||
## theme.css
|
||||
|
||||
```css
|
||||
/* Colors from colorThemes.json */
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
|
||||
/* Buttons from theme-options/buttons/ */
|
||||
.primary-button { /* ... */ }
|
||||
.secondary-button { /* ... */ }
|
||||
|
||||
/* Cards from theme-options/cards/ */
|
||||
.card { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a Template
|
||||
|
||||
1. Create `src/templates/[name]/page.tsx` and `theme.css`
|
||||
2. Define types matching your chosen sections
|
||||
3. Create defaultProps with demo content
|
||||
4. Copy section code inline (don't import sections)
|
||||
5. Add base markers to each section
|
||||
6. Build theme.css from colorThemes.json + theme-options/
|
||||
|
||||
---
|
||||
|
||||
## Updating Templates
|
||||
|
||||
When a base section changes, find affected templates with grep and apply the fix to each inline section.
|
||||
|
||||
---
|
||||
|
||||
## Not Included
|
||||
|
||||
- Not in registry.json (standalone files)
|
||||
- No ThemeProvider
|
||||
- No imported sections (inline code only)
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
index.html
Normal file
21
index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!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=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&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>Beverly Hills Dentistry | Premier Dental Care</title>
|
||||
<meta property="og:title" content="Beverly Hills Dentistry | Premier Dental Care" />
|
||||
<meta name="twitter:title" content="Beverly Hills Dentistry | Premier Dental Care" />
|
||||
<meta name="description" content="Experience unparalleled dental care in Beverly Hills. We offer cosmetic, general, and orthodontic services with a focus on patient comfort and stunning results." />
|
||||
<meta property="og:description" content="Experience unparalleled dental care in Beverly Hills. We offer cosmetic, general, and orthodontic services with a focus on patient comfort and stunning results." />
|
||||
<meta name="twitter:description" content="Experience unparalleled dental care in Beverly Hills. We offer cosmetic, general, and orthodontic services with a focus on patient comfort and stunning results." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "webild-components",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rive-app/react-canvas": "^4.28.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "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"
|
||||
}
|
||||
}
|
||||
2284
pnpm-lock.yaml
generated
Normal file
2284
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 |
2018
registry.json
Normal file
2018
registry.json
Normal file
File diff suppressed because it is too large
Load Diff
290
src/App.tsx
Normal file
290
src/App.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||
import NavbarCentered from "@/components/ui/NavbarCentered";
|
||||
import HeroOverlay from "@/components/sections/hero/HeroOverlay";
|
||||
import AboutTextSplit from "@/components/sections/about/AboutTextSplit";
|
||||
import FeaturesDetailedCards from "@/components/sections/features/FeaturesDetailedCards";
|
||||
import MetricsMediaCards from "@/components/sections/metrics/MetricsMediaCards";
|
||||
import TestimonialRatingCards from "@/components/sections/testimonial/TestimonialRatingCards";
|
||||
import TeamOverlayCards from "@/components/sections/team/TeamOverlayCards";
|
||||
import FaqSimple from "@/components/sections/faq/FaqSimple";
|
||||
import ContactSplitForm from "@/components/sections/contact/ContactSplitForm";
|
||||
import FooterSimpleMedia from "@/components/sections/footer/FooterSimpleMedia";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<ThemeProvider
|
||||
defaultButtonVariant="primary"
|
||||
defaultTextAnimation="slide-in-from-left"
|
||||
borderRadius="soft"
|
||||
contentWidth="mediumLarge"
|
||||
sizing="default"
|
||||
background="subtle-gradient"
|
||||
cardStyle="layered-gradient"
|
||||
primaryButtonStyle="accent-edge"
|
||||
secondaryButtonStyle="glass"
|
||||
headingFontWeight="bold"
|
||||
>
|
||||
<div
|
||||
id="nav"
|
||||
data-section="nav"
|
||||
data-webild-component="NavbarCentered"
|
||||
data-webild-component-path="@/components/ui/NavbarCentered"
|
||||
>
|
||||
<NavbarCentered
|
||||
logo="Beverly Hills Dentistry"
|
||||
navItems={[
|
||||
{ name: "Home", href: "#home" },
|
||||
{ name: "About Us", href: "#about" },
|
||||
{ name: "Services", href: "#services" },
|
||||
{ name: "Team", href: "#team" },
|
||||
{ name: "FAQ", href: "#faq" },
|
||||
{ name: "Contact", href: "#contact" }
|
||||
]}
|
||||
ctaButton={{ text: "Book Appointment", href: "#contact" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="home"
|
||||
data-section="home"
|
||||
data-webild-component="HeroOverlay"
|
||||
data-webild-component-path="@/components/sections/hero/HeroOverlay"
|
||||
>
|
||||
<HeroOverlay
|
||||
tag="Your Brightest Smile Starts Here"
|
||||
title="Beverly Hills' Premier Dental Care"
|
||||
description="Experience unparalleled dental excellence with a personalized touch. Our state-of-the-art practice offers comprehensive care to perfect your smile."
|
||||
primaryButton={{ text: "Book Your Appointment", href: "#contact" }}
|
||||
secondaryButton={{ text: "Explore Services", href: "#services" }}
|
||||
imageSrc="http://img.b2bpic.net/free-photo/joyful-beautiful-blonde-woman-wearing-white-shirt-standing-co-working-space-leaning-desk_74855-15168.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="about"
|
||||
data-section="about"
|
||||
data-webild-component="AboutTextSplit"
|
||||
data-webild-component-path="@/components/sections/about/AboutTextSplit"
|
||||
>
|
||||
<AboutTextSplit
|
||||
title="A Legacy of Radiant Smiles in Beverly Hills"
|
||||
descriptions={[
|
||||
"At Beverly Hills Dentistry, we blend art with science to deliver exceptional dental care. Our commitment to excellence, combined with the latest technology, ensures a comfortable and effective experience for every patient.", "Dr. [Dentist's Name] and our dedicated team are passionate about creating healthy, beautiful smiles that last a lifetime. We pride ourselves on a patient-centric approach, understanding your unique needs and crafting bespoke treatment plans to achieve your aesthetic and health goals."
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="services"
|
||||
data-section="services"
|
||||
data-webild-component="FeaturesDetailedCards"
|
||||
data-webild-component-path="@/components/sections/features/FeaturesDetailedCards"
|
||||
>
|
||||
<FeaturesDetailedCards
|
||||
tag="Our Comprehensive Dental Offerings"
|
||||
title="Elevating Your Oral Health and Aesthetics"
|
||||
description="From routine check-ups to advanced cosmetic procedures, we provide a full spectrum of dental services designed to meet your every need."
|
||||
items={[
|
||||
{
|
||||
title: "Cosmetic Dentistry", description: "Achieve your dream smile with veneers, teeth whitening, and smile makeovers tailored to your unique facial features.", tags: ["Whitening", "Veneers", "Smile Makeovers"],
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/front-close-view-young-female-dark-jacket-excited-pink-background_179666-30951.jpg"
|
||||
},
|
||||
{
|
||||
title: "General Dentistry", description: "Maintain optimal oral health with our preventative care, fillings, root canals, and regular dental examinations.", tags: ["Check-ups", "Cleanings", "Fillings"],
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/beautiful-young-woman-making-make-up-near-mirror-home_1301-7649.jpg"
|
||||
},
|
||||
{
|
||||
title: "Orthodontics", description: "Straighten your smile discreetly and effectively with modern orthodontic solutions, including Invisalign and traditional braces.", tags: ["Invisalign", "Braces", "Clear Aligners"],
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/close-up-portrait-happy-beautiful-girl-listens-music-wireless-headphones-shows-peace-vsign_1258-201754.jpg"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="metrics"
|
||||
data-section="metrics"
|
||||
data-webild-component="MetricsMediaCards"
|
||||
data-webild-component-path="@/components/sections/metrics/MetricsMediaCards"
|
||||
>
|
||||
<MetricsMediaCards
|
||||
tag="Our Commitment to Excellence"
|
||||
title="Results You Can Smile About"
|
||||
description="Our practice is built on a foundation of trust, expertise, and a genuine passion for transforming smiles and lives."
|
||||
metrics={[
|
||||
{
|
||||
value: "98%", title: "Patient Satisfaction", description: "Our patients consistently rate their experience as outstanding.", imageSrc: "http://img.b2bpic.net/free-photo/portrait-smiling-young-woman-sitting-chair-dental-clinic_662251-2583.jpg"
|
||||
},
|
||||
{
|
||||
value: "20+", title: "Years of Experience", description: "Serving the Beverly Hills community with seasoned expertise and innovative care.", imageSrc: "http://img.b2bpic.net/free-photo/smiley-chef-showing-ok-sign_23-2148480354.jpg"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="testimonials"
|
||||
data-section="testimonials"
|
||||
data-webild-component="TestimonialRatingCards"
|
||||
data-webild-component-path="@/components/sections/testimonial/TestimonialRatingCards"
|
||||
>
|
||||
<TestimonialRatingCards
|
||||
tag="Hear From Our Valued Patients"
|
||||
title="Trusted by the Beverly Hills Community"
|
||||
description="Real stories from individuals who have experienced the transformative care at our practice."
|
||||
testimonials={[
|
||||
{
|
||||
name: "Sophia L.", role: "Actress", quote: "Dr. [Dentist's Name] gave me the most stunning smile. The attention to detail and personalized care is truly unmatched. I feel more confident than ever!", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/portrait-beauty-businesswoman-leaning-against-brick-wall_613910-773.jpg"
|
||||
},
|
||||
{
|
||||
name: "Michael R.", role: "Real Estate Broker", quote: "Professional, efficient, and incredibly friendly. My dental health has improved dramatically, and I always look forward to my appointments. Highly recommend!", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/young-woman-showing-double-thumbs-up-white-shirt-looking-happy_176474-17469.jpg"
|
||||
},
|
||||
{
|
||||
name: "Olivia B.", role: "Fashion Designer", quote: "The entire team at Beverly Hills Dentistry made me feel so comfortable. My teeth whitening results were phenomenal. Truly a five-star experience!", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/smiling-woman-running-holding-bottle-with-water_23-2148138021.jpg"
|
||||
},
|
||||
{
|
||||
name: "David K.", role: "Entrepreneur", quote: "Exceptional service from start to finish. They utilize cutting-edge technology and genuinely care about their patients. Best dentist I've ever had.", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/beautiful-happy-retired-woman-wearing-cozy-sweater-short-hairdo_343059-1204.jpg"
|
||||
},
|
||||
{
|
||||
name: "Emily C.", role: "Art Curator", quote: "My Invisalign journey was seamless thanks to their expert guidance. My smile is now perfectly aligned, and I couldn't be happier with the results.", rating: 5,
|
||||
imageSrc: "http://img.b2bpic.net/free-photo/cute-woman-casual-indoor-home_624325-1369.jpg"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="team"
|
||||
data-section="team"
|
||||
data-webild-component="TeamOverlayCards"
|
||||
data-webild-component-path="@/components/sections/team/TeamOverlayCards"
|
||||
>
|
||||
<TeamOverlayCards
|
||||
tag="Meet Your Dental Care Family"
|
||||
title="Our Dedicated Team of Experts"
|
||||
description="Get to know the passionate professionals committed to providing you with the highest standard of dental care and a warm, inviting experience."
|
||||
members={[
|
||||
{
|
||||
name: "Dr. Elena Petrova", role: "Lead Dentist & Founder", imageSrc: "http://img.b2bpic.net/free-photo/smiling-dentist-holding-dental-tools_107420-73940.jpg"
|
||||
},
|
||||
{
|
||||
name: "Dr. Marcus Chen", role: "Associate Dentist", imageSrc: "http://img.b2bpic.net/free-photo/dentist-work-young-woman-dentist-treating-kid-tooth-dentistry-concept_169016-67155.jpg"
|
||||
},
|
||||
{
|
||||
name: "Sarah Miller", role: "Head Hygienist", imageSrc: "http://img.b2bpic.net/free-photo/female-doctor-holding-her-chin-looking-camera-high-quality-photo_114579-60424.jpg"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="faq"
|
||||
data-section="faq"
|
||||
data-webild-component="FaqSimple"
|
||||
data-webild-component-path="@/components/sections/faq/FaqSimple"
|
||||
>
|
||||
<FaqSimple
|
||||
tag="Common Questions, Clear Answers"
|
||||
title="Your Guide to Our Dental Practice"
|
||||
description="Find quick answers to the most frequently asked questions about our services, appointments, and patient care."
|
||||
items={[
|
||||
{
|
||||
question: "What insurance plans do you accept?", answer: "We accept most major PPO insurance plans and are happy to help you understand your benefits. Please contact our office for specific inquiries regarding your coverage."
|
||||
},
|
||||
{
|
||||
question: "How often should I visit the dentist?", answer: "For optimal oral health, we recommend visiting us for a routine check-up and cleaning every six months. Your specific needs may vary, and we'll advise you on the best schedule."
|
||||
},
|
||||
{
|
||||
question: "Do you offer emergency dental services?", answer: "Yes, we prioritize dental emergencies and aim to see patients as soon as possible. Please call our office immediately if you are experiencing a dental emergency."
|
||||
},
|
||||
{
|
||||
question: "What cosmetic procedures do you specialize in?", answer: "We specialize in a range of cosmetic treatments including porcelain veneers, professional teeth whitening, dental bonding, and full smile makeovers to enhance your smile's aesthetics."
|
||||
},
|
||||
{
|
||||
question: "Is Invisalign a good option for me?", answer: "Invisalign is an excellent option for many adults and teens seeking to straighten their teeth discreetly. We offer complimentary consultations to assess your suitability for Invisalign and discuss your treatment goals."
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="contact"
|
||||
data-section="contact"
|
||||
data-webild-component="ContactSplitForm"
|
||||
data-webild-component-path="@/components/sections/contact/ContactSplitForm"
|
||||
>
|
||||
<ContactSplitForm
|
||||
tag="Connect With Us Today"
|
||||
title="Schedule Your Visit to Our Beverly Hills Office"
|
||||
description="Ready for a healthier, brighter smile? Contact us to book an appointment or inquire about our services. Our friendly team is here to assist you."
|
||||
inputs={[
|
||||
{
|
||||
name: "name", type: "text", placeholder: "Your Name", required: true
|
||||
},
|
||||
{
|
||||
name: "email", type: "email", placeholder: "Your Email", required: true
|
||||
},
|
||||
{
|
||||
name: "phone", type: "tel", placeholder: "Your Phone Number"
|
||||
}
|
||||
]}
|
||||
textarea={{
|
||||
name: "message", placeholder: "Your Message or Inquiry", rows: 5,
|
||||
required: true
|
||||
}}
|
||||
buttonText="Send Message"
|
||||
imageSrc="http://img.b2bpic.net/free-photo/full-shot-man-with-bicycle-going-work_23-2149396249.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="footer"
|
||||
data-section="footer"
|
||||
data-webild-component="FooterSimpleMedia"
|
||||
data-webild-component-path="@/components/sections/footer/FooterSimpleMedia"
|
||||
>
|
||||
<FooterSimpleMedia
|
||||
imageSrc="http://img.b2bpic.net/free-photo/close-up-dentist-instruments_23-2151042920.jpg"
|
||||
brand="Beverly Hills Dentistry"
|
||||
columns={[
|
||||
{
|
||||
title: "Services", items: [
|
||||
{ label: "Cosmetic Dentistry", href: "#services" },
|
||||
{ label: "General Dentistry", href: "#services" },
|
||||
{ label: "Orthodontics", href: "#services" },
|
||||
{ label: "Emergency Care", href: "#faq" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "About Us", items: [
|
||||
{ label: "Our Philosophy", href: "#about" },
|
||||
{ label: "Meet The Team", href: "#team" },
|
||||
{ label: "Patient Testimonials", href: "#testimonials" },
|
||||
{ label: "FAQs", href: "#faq" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Contact", items: [
|
||||
{ label: "Book Appointment", href: "#contact" },
|
||||
{ label: "Location", href: "#contact" },
|
||||
{ label: "Call Us: (310) 555-1234", href: "tel:+13105551234" },
|
||||
{ label: "Email: info@beverlyhillsdentistry.com", href: "mailto:info@beverlyhillsdentistry.com" }
|
||||
]
|
||||
}
|
||||
]}
|
||||
copyright="© 2024 Beverly Hills Dentistry. All rights reserved."
|
||||
links={[
|
||||
{ label: "Privacy Policy", href: "#" },
|
||||
{ label: "Terms of Service", href: "#" }
|
||||
]}
|
||||
/>
|
||||
</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-medium text-foreground">Cart ({items.length})</h2>
|
||||
<button onClick={onClose} className="card flex items-center justify-center size-8 rounded cursor-pointer" aria-label="Close cart">
|
||||
<X className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 h-px w-full bg-foreground/10" />
|
||||
|
||||
<div className="flex-1 py-5 min-h-0 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<p className="py-20 text-center text-sm text-foreground/50">Your cart is empty</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-4">
|
||||
<div className="shrink-0 size-24 overflow-hidden rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} className="size-full object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-medium text-foreground truncate">{item.name}</h3>
|
||||
<p className="shrink-0 text-base font-medium text-foreground">{item.price}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => item.quantity > 1 && onQuantityChange?.(item.id, item.quantity - 1)}
|
||||
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Minus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
<span className="min-w-5 text-center text-sm font-medium text-foreground">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => onQuantityChange?.(item.id, item.quantity + 1)}
|
||||
className="card flex items-center justify-center size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Plus className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove?.(item.id)}
|
||||
className="card flex items-center justify-center ml-auto size-8 rounded cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-4 text-foreground" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="h-px w-full bg-foreground/10" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-medium text-foreground">Total</span>
|
||||
<span className="text-base font-medium text-foreground">{total}</span>
|
||||
</div>
|
||||
<Button text="Checkout" onClick={onCheckout} variant="primary" className="w-full" />
|
||||
</div>
|
||||
</motion.aside>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCart;
|
||||
export type { CartItem };
|
||||
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
142
src/components/ecommerce/ProductCatalog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Star, ArrowUpRight, Loader2 } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import useProducts from "@/hooks/useProducts";
|
||||
import type { ProductVariant } from "./ProductDetailCard";
|
||||
|
||||
type CatalogProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
imageSrc: string;
|
||||
category?: string;
|
||||
rating?: number;
|
||||
reviewCount?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ProductCatalogProps = {
|
||||
products?: CatalogProduct[];
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
filters?: ProductVariant[];
|
||||
};
|
||||
|
||||
const ProductCatalog = ({ products: productsProp, searchValue = "", onSearchChange, filters }: ProductCatalogProps) => {
|
||||
const { products: fetchedProducts, isLoading } = useProducts();
|
||||
|
||||
const products: CatalogProduct[] = productsProp && productsProp.length > 0
|
||||
? productsProp
|
||||
: fetchedProducts.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
imageSrc: p.imageSrc,
|
||||
category: p.brand,
|
||||
rating: p.rating,
|
||||
reviewCount: p.reviewCount,
|
||||
onClick: p.onProductClick,
|
||||
}));
|
||||
|
||||
if (isLoading && (!productsProp || productsProp.length === 0)) {
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-8 text-foreground animate-spin" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
{(onSearchChange || (filters && filters.length > 0)) && (
|
||||
<div className="flex flex-col gap-5 mb-5 md:flex-row md:items-end">
|
||||
{onSearchChange && (
|
||||
<div className="flex flex-1 flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-medium text-foreground">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
className="card px-4 h-9 w-full md:w-80 text-base text-foreground bg-transparent rounded focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-5 items-end">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.label} className="flex flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-medium text-foreground">{filter.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||
<select
|
||||
value={filter.selected}
|
||||
onChange={(e) => filter.onChange(e.target.value)}
|
||||
className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none"
|
||||
>
|
||||
{filter.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<p className="py-20 text-center text-sm text-foreground/50">No products found</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{products.map((product) => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={product.onClick}
|
||||
className="card group h-full flex flex-col gap-3 p-3 text-left rounded cursor-pointer"
|
||||
>
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={product.imageSrc} className="size-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div className="absolute inset-0 flex items-center justify-center transition-all duration-300 group-hover:bg-background/20 group-hover:backdrop-blur-xs">
|
||||
<div className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100">
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{product.category && (
|
||||
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.category}</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-xl font-medium text-foreground truncate">{product.name}</h3>
|
||||
{product.rating && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cls("size-4 text-accent", i < Math.floor(product.rating || 0) ? "fill-accent" : "opacity-20")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{product.reviewCount && (
|
||||
<span className="text-sm text-foreground">({product.reviewCount})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-medium text-foreground">{product.price}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCatalog;
|
||||
export type { CatalogProduct };
|
||||
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
137
src/components/ecommerce/ProductDetailCard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
|
||||
type ProductVariant = {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type ProductDetailCardProps = {
|
||||
name: string;
|
||||
price: string;
|
||||
salePrice?: string;
|
||||
images: string[];
|
||||
description?: string;
|
||||
rating?: number;
|
||||
ribbon?: string;
|
||||
inventoryStatus?: "in-stock" | "out-of-stock";
|
||||
inventoryQuantity?: number;
|
||||
sku?: string;
|
||||
variants?: ProductVariant[];
|
||||
quantity?: ProductVariant;
|
||||
onAddToCart?: () => void;
|
||||
onBuyNow?: () => void;
|
||||
};
|
||||
|
||||
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
return (
|
||||
<section className="mx-auto py-20 w-content-width">
|
||||
<div className="flex flex-col gap-5 md:flex-row">
|
||||
<div className="relative md:w-1/2">
|
||||
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
|
||||
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
|
||||
</Transition>
|
||||
{images.length > 1 && (
|
||||
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(i)}
|
||||
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
|
||||
>
|
||||
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
|
||||
<div className={cls(
|
||||
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
|
||||
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
|
||||
)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<h2 className="flex-1 text-2xl font-medium text-foreground md:text-3xl">{name}</h2>
|
||||
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-medium rounded text-secondary-cta-text">{ribbon}</span>}
|
||||
</div>
|
||||
<div className="h-px w-full bg-foreground/10" />
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xl font-medium text-foreground md:text-2xl">
|
||||
{salePrice ? (
|
||||
<>
|
||||
<span className="text-foreground/75 line-through mr-1">{price}</span>
|
||||
<span>{salePrice}</span>
|
||||
</>
|
||||
) : (
|
||||
price
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(inventoryStatus || inventoryQuantity || sku) && (
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{inventoryStatus && (
|
||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
|
||||
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
|
||||
</span>
|
||||
)}
|
||||
{inventoryQuantity && (
|
||||
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
|
||||
)}
|
||||
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
|
||||
</div>
|
||||
)}
|
||||
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
|
||||
{variants && variants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-5">
|
||||
{variants.map((variant) => (
|
||||
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
|
||||
<label className="text-sm font-medium text-foreground">{variant.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 rounded">
|
||||
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||
{variant.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{quantity && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-foreground">{quantity.label}</label>
|
||||
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
|
||||
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
|
||||
{quantity.options.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col mt-auto gap-3 pt-5">
|
||||
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
|
||||
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailCard;
|
||||
export type { ProductVariant };
|
||||
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;
|
||||
88
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
88
src/components/sections/about/AboutFeaturesSplit.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type AboutFeaturesSplitProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: { icon: string | LucideIcon; title: string; description: string }[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const AboutFeaturesSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: AboutFeaturesSplitProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="flex flex-col gap-8 mx-auto w-content-width">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
|
||||
<div className="flex flex-col justify-center gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 w-full md:w-4/10 2xl:w-3/10 card rounded">
|
||||
{items.map((item, index) => {
|
||||
const ItemIcon = resolveIcon(item.icon);
|
||||
return (
|
||||
<div key={item.title}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
|
||||
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium">{item.title}</h3>
|
||||
<p className="text-base leading-tight">{item.description}</p>
|
||||
</div>
|
||||
{index < items.length - 1 && (
|
||||
<div className="mt-5 border-b border-accent/40" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-3 xl:p-4 2xl:p-5 w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
|
||||
<div className="relative size-full">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutFeaturesSplit;
|
||||
72
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
72
src/components/sections/about/AboutMediaOverlay.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type AboutMediaOverlayProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const AboutMediaOverlay = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: AboutMediaOverlayProps) => {
|
||||
return (
|
||||
<section aria-label="About section" className="py-20">
|
||||
<div className="relative flex items-center justify-center py-8 md:py-12 mx-auto w-content-width rounded overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
<div className="absolute inset-0 bg-foreground/40 backdrop-blur-xs pointer-events-none select-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex items-center justify-center px-5 py-10 mx-auto min-h-100 md:min-h-120 md:w-1/2 w-content-width">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-15%" }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="mb-1 px-3 py-1 text-sm card rounded"
|
||||
>
|
||||
{tag}
|
||||
</motion.span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-balance text-primary-cta-text"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-primary-cta-text"
|
||||
/>
|
||||
|
||||
{(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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutMediaOverlay;
|
||||
56
src/components/sections/about/AboutTestimonial.tsx
Normal file
56
src/components/sections/about/AboutTestimonial.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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-8 md:px-12 md:py-20 card rounded">
|
||||
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
|
||||
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col justify-center gap-5 py-8 md:py-5 h-full">
|
||||
<span className="w-fit px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={quote}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-3xl md:text-4xl font-medium leading-tight"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{author}</span>
|
||||
<span className="text-accent">•</span>
|
||||
<span className="text-base">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" className="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;
|
||||
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-3 items-center">
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="h2"
|
||||
className="text-2xl md:text-5xl font-medium text-center leading-tight text-balance"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 justify-center 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-30 mx-auto w-content-width">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
|
||||
<div className="w-full md:w-1/2">
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-7xl font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 w-full md:w-1/2">
|
||||
{descriptions.map((desc, index) => (
|
||||
<TextAnimation
|
||||
key={index}
|
||||
text={desc}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-2xl leading-tight"
|
||||
/>
|
||||
))}
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap max-md:justify-center gap-5">
|
||||
{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/10" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutTextSplit;
|
||||
154
src/components/sections/blog/BlogMediaCards.tsx
Normal file
154
src/components/sections/blog/BlogMediaCards.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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-5 p-5 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="card w-fit rounded px-2 py-0.5 text-xs mb-0.5">{item.category}</span>
|
||||
|
||||
<h3 className="text-2xl md:text-3xl font-medium leading-tight line-clamp-2">{item.title}</h3>
|
||||
<p className="text-sm leading-tight opacity-75 line-clamp-2">{item.excerpt}</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-9 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{item.authorName}</span>
|
||||
<span className="text-xs opacity-75">{item.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type BlogMediaCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items?: BlogItem[];
|
||||
};
|
||||
|
||||
const BlogMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items: itemsProp,
|
||||
}: BlogMediaCardsProps) => {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
const isFromApi = posts.length > 0;
|
||||
const items = isFromApi
|
||||
? posts.map((p) => ({
|
||||
category: p.category,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
authorName: p.authorName,
|
||||
authorImageSrc: p.authorAvatar,
|
||||
date: p.date,
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onBlogClick,
|
||||
}))
|
||||
: itemsProp;
|
||||
|
||||
if (isLoading && !itemsProp) {
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogMediaCards;
|
||||
157
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
157
src/components/sections/blog/BlogSimpleCards.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative aspect-4/3 rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between gap-3 xl:gap-4 2xl:gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="primary-button w-fit rounded px-2 py-0.5 text-xs text-primary-cta-text">
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 className="text-xl font-medium leading-tight mt-1">{item.title}</h3>
|
||||
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-9 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{item.authorName}</span>
|
||||
<span className="text-xs opacity-75">{item.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type BlogSimpleCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items?: BlogItem[];
|
||||
};
|
||||
|
||||
const BlogSimpleCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items: itemsProp,
|
||||
}: BlogSimpleCardsProps) => {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
const isFromApi = posts.length > 0;
|
||||
const items = isFromApi
|
||||
? posts.map((p) => ({
|
||||
category: p.category,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
authorName: p.authorName,
|
||||
authorImageSrc: p.authorAvatar,
|
||||
date: p.date,
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onBlogClick,
|
||||
}))
|
||||
: itemsProp;
|
||||
|
||||
if (isLoading && !itemsProp) {
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogSimpleCards;
|
||||
164
src/components/sections/blog/BlogTagCards.tsx
Normal file
164
src/components/sections/blog/BlogTagCards.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 rounded cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative aspect-4/3 rounded overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
|
||||
<button
|
||||
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between gap-3 xl:gap-4 2xl:gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.authorImageSrc}
|
||||
className="size-5 rounded-full object-cover"
|
||||
/>
|
||||
<span className="text-xs opacity-75">
|
||||
{item.authorName} • {item.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-medium leading-tight">{item.title}</h3>
|
||||
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="primary-button rounded px-2 py-0.5 text-xs text-primary-cta-text"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type BlogTagCardsProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items?: BlogItem[];
|
||||
};
|
||||
|
||||
const BlogTagCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items: itemsProp,
|
||||
}: BlogTagCardsProps) => {
|
||||
const { posts, isLoading } = useBlogPosts();
|
||||
const isFromApi = posts.length > 0;
|
||||
const items = isFromApi
|
||||
? posts.map((p) => ({
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
authorName: p.authorName,
|
||||
authorImageSrc: p.authorAvatar,
|
||||
date: p.date,
|
||||
tags: [p.category],
|
||||
imageSrc: p.imageSrc,
|
||||
onClick: p.onBlogClick,
|
||||
}))
|
||||
: itemsProp;
|
||||
|
||||
if (isLoading && !itemsProp) {
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Blog section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item, index) => (
|
||||
<BlogCardItem key={index} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogTagCards;
|
||||
90
src/components/sections/contact/ContactCenter.tsx
Normal file
90
src/components/sections/contact/ContactCenter.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import { sendContactEmail } from "@/lib/api/email";
|
||||
|
||||
const ContactCenter = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputPlaceholder,
|
||||
buttonText,
|
||||
termsText,
|
||||
onSubmit,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputPlaceholder: string;
|
||||
buttonText: string;
|
||||
termsText?: string;
|
||||
onSubmit?: (email: string) => void;
|
||||
}) => {
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await sendContactEmail({ email });
|
||||
onSubmit?.(email);
|
||||
setEmail("");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<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-3 px-5">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-5xl md:text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 card rounded"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={inputPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{termsText && (
|
||||
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
|
||||
{termsText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactCenter;
|
||||
42
src/components/sections/contact/ContactCta.tsx
Normal file
42
src/components/sections/contact/ContactCta.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
const ContactCta = ({
|
||||
tag,
|
||||
text,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
}: {
|
||||
tag: string;
|
||||
text: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
}) => {
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="slide-up" className="flex items-center justify-center py-20 px-5 md:px-10 card rounded">
|
||||
<div className="w-full md:w-3/4 flex flex-col items-center gap-3">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={text}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-4xl md:text-5xl font-medium text-center leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-1">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactCta;
|
||||
101
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
101
src/components/sections/contact/ContactSplitEmail.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
termsText?: string;
|
||||
onSubmit?: (email: string) => void;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const ContactSplitEmail = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
inputPlaceholder,
|
||||
buttonText,
|
||||
termsText,
|
||||
onSubmit,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: ContactSplitEmailProps) => {
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await sendContactEmail({ email });
|
||||
onSubmit?.(email);
|
||||
setEmail("");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<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-3 px-5">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-5xl md:text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-8/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 card rounded"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={inputPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{termsText && (
|
||||
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
|
||||
{termsText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-100 md:h-auto card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitEmail;
|
||||
140
src/components/sections/contact/ContactSplitForm.tsx
Normal file
140
src/components/sections/contact/ContactSplitForm.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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 handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await sendContactEmail({ formData });
|
||||
onSubmit?.(formData);
|
||||
const reset: Record<string, string> = {};
|
||||
inputs.forEach((input) => {
|
||||
reset[input.name] = "";
|
||||
});
|
||||
if (textarea) {
|
||||
reset[textarea.name] = "";
|
||||
}
|
||||
setFormData(reset);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Contact section" className="py-20">
|
||||
<div className="w-content-width mx-auto">
|
||||
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 md:auto-rows-fr gap-5">
|
||||
<div className="p-5 md:p-10 card rounded">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-4xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-sm md:text-base leading-tight text-balance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{inputs.map((input) => (
|
||||
<input
|
||||
key={input.name}
|
||||
type={input.type}
|
||||
placeholder={input.placeholder}
|
||||
value={formData[input.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
|
||||
required={input.required}
|
||||
aria-label={input.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
|
||||
/>
|
||||
))}
|
||||
|
||||
{textarea && (
|
||||
<textarea
|
||||
placeholder={textarea.placeholder}
|
||||
value={formData[textarea.name] || ""}
|
||||
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
|
||||
required={textarea.required}
|
||||
rows={textarea.rows || 5}
|
||||
aria-label={textarea.placeholder}
|
||||
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer primary-button"
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="h-100 md:h-full md:relative card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="md:absolute md:inset-0 size-full object-cover" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSplitForm;
|
||||
105
src/components/sections/faq/FaqSimple.tsx
Normal file
105
src/components/sections/faq/FaqSimple.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleToggle(index)}
|
||||
className="p-3 2xl:p-4 rounded card cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-sm leading-tight">{item.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqSimple;
|
||||
120
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
120
src/components/sections/faq/FaqSplitMedia.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
|
||||
<ScrollReveal variant="fade" 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="fade" delay={0.1} className="md:col-span-3 flex flex-col gap-3">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleToggle(index)}
|
||||
className="p-3 2xl:p-4 rounded secondary-button cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-sm leading-tight">{item.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqSplitMedia;
|
||||
120
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
120
src/components/sections/faq/FaqTwoColumn.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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 2xl:p-4 rounded secondary-button cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
|
||||
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 rounded primary-button">
|
||||
<Plus
|
||||
className={cls(
|
||||
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
|
||||
activeIndex === index && "rotate-45"
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{activeIndex === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-1 text-sm leading-tight">{item.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label="FAQ section" className="py-20">
|
||||
<div className="w-content-width mx-auto flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="fade" className="card rounded p-3 xl:p-4 2xl:p-5">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:gap-5">
|
||||
<div className="flex flex-1 flex-col gap-3 md:gap-5">
|
||||
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
|
||||
</div>
|
||||
{secondColumn.length > 0 && (
|
||||
<div className="flex flex-1 flex-col gap-3 md:gap-5">
|
||||
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqTwoColumn;
|
||||
@@ -0,0 +1,92 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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-blur"
|
||||
key={item.title}
|
||||
className={cls("flex flex-col gap-5 md:gap-12 p-5 md:p-12 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
|
||||
>
|
||||
<div className="flex flex-col justify-center w-full md:w-1/2 gap-3">
|
||||
<span className="flex items-center justify-center size-8 mb-1 text-sm rounded primary-button text-primary-cta-text">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h3 className="text-4xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-tight text-balance">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesAlternatingSplit;
|
||||
87
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
87
src/components/sections/features/FeaturesArrowCards.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
tags: string[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesArrowCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesArrowCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesArrowCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="fade">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col gap-5 h-full cursor-pointer group">
|
||||
<div className="aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 flex-1 card rounded">
|
||||
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.tags.map((itemTag) => (
|
||||
<span key={itemTag} className="px-3 py-1 mb-1 text-sm card rounded">{itemTag}</span>
|
||||
))}
|
||||
</div>
|
||||
<ArrowRight className="shrink-0 h-[1em] w-auto transition-transform duration-300 group-hover:-rotate-45" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesArrowCards;
|
||||
101
src/components/sections/features/FeaturesBento.tsx
Normal file
101
src/components/sections/features/FeaturesBento.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded h-full">
|
||||
<div className="relative h-72 overflow-hidden">{getBentoComponent(feature)}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
|
||||
<p className="text-sm leading-tight">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default FeaturesBento;
|
||||
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="py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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>
|
||||
{features.map((feature) => {
|
||||
const FeatureIcon = resolveIcon(feature.icon);
|
||||
return (
|
||||
<div key={feature.title} className="relative flex flex-col justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 h-full min-h-60 card rounded">
|
||||
<div className="flex items-center justify-center size-12 primary-button rounded">
|
||||
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
|
||||
<p className="text-sm leading-tight">{feature.description}</p>
|
||||
</div>
|
||||
<BorderGlow />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesBorderGlow;
|
||||
79
src/components/sections/features/FeaturesComparison.tsx
Normal file
79
src/components/sections/features/FeaturesComparison.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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" 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-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded opacity-50">
|
||||
{negativeItems.map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-base">
|
||||
<X className="shrink-0 h-[1em] w-auto" />
|
||||
<span className="text-base truncate">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded">
|
||||
{positiveItems.map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-base">
|
||||
<Check className="shrink-0 h-[1em] w-auto" />
|
||||
<span className="text-base truncate">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesComparison;
|
||||
93
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
93
src/components/sections/features/FeaturesDetailedCards.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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="fade"
|
||||
key={item.title}
|
||||
className="flex flex-col md:grid md:grid-cols-10 2xl:w-8/10 mx-auto gap-5 md:gap-10 p-5 md:p-10 cursor-pointer card rounded group"
|
||||
>
|
||||
<div className="flex flex-col md:col-span-6 gap-3 md:gap-12">
|
||||
<h3 className="text-3xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||
|
||||
<div className="flex flex-col mt-auto gap-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.tags.map((itemTag) => (
|
||||
<span key={itemTag} className="px-3 py-1 mb-1 text-sm card rounded">{itemTag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-base md:text-2xl leading-tight text-balance">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square md:col-span-4 rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedCards;
|
||||
96
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
96
src/components/sections/features/FeaturesDetailedSteps.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<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="fade"
|
||||
key={step.title}
|
||||
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-5 md:gap-12 p-5 md:p-12 card rounded overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col justify-between w-full md:w-1/2">
|
||||
<div className="flex flex-col gap-5">
|
||||
<span className="w-fit px-3 py-1 mb-1 text-sm card rounded">{step.tag}</span>
|
||||
<h3 className="text-5xl md:text-8xl font-medium leading-none">{step.title}</h3>
|
||||
</div>
|
||||
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
|
||||
<div className="flex flex-col gap-2 md:gap-5">
|
||||
<h4 className="text-2xl md:text-3xl font-medium text-balance">{step.subtitle}</h4>
|
||||
<p className="text-base md:text-lg leading-tight text-balance">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full md:w-35/100 gap-10">
|
||||
<span className="hidden md:block self-end text-8xl font-medium text-accent">{stepNumber}</span>
|
||||
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
|
||||
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesDetailedSteps;
|
||||
115
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
115
src/components/sections/features/FeaturesFlipCards.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
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-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded backface-hidden">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
|
||||
<Plus className="h-2/5 w-2/5 text-primary-cta-text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden aspect-4/5 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded backface-hidden transform-[rotateY(180deg)]">
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
|
||||
<Plus className="h-2/5 w-2/5 rotate-45 text-primary-cta-text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{item.descriptions.map((desc, index) => (
|
||||
<p key={index} className="text-lg leading-tight">{desc}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesFlipCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesFlipCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<FeatureFlipCard key={item.title} item={item} />
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesFlipCards;
|
||||
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="py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded">
|
||||
<HoverPattern className="flex items-center justify-center aspect-square rounded">
|
||||
<div className="relative z-10 flex items-center justify-center size-12 primary-button rounded shadow">
|
||||
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
</HoverPattern>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
|
||||
<p className="text-sm leading-tight">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesIconCards;
|
||||
99
src/components/sections/features/FeaturesLabeledList.tsx
Normal file
99
src/components/sections/features/FeaturesLabeledList.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Fragment } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
label: string;
|
||||
title: string;
|
||||
bullets: string[];
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
};
|
||||
|
||||
interface FeaturesLabeledListProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesLabeledList = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesLabeledListProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<div className="flex flex-col gap-5 w-content-width mx-auto">
|
||||
{items.map((item) => (
|
||||
<ScrollReveal
|
||||
variant="slide-up"
|
||||
key={item.label}
|
||||
className="flex flex-col md:flex-row gap-5 md:gap-12 p-5 md:p-12 card rounded"
|
||||
>
|
||||
<div className="w-full md:w-1/2 flex md:justify-start">
|
||||
<h3 className="text-7xl font-medium leading-tight truncate">{item.label}</h3>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20 md:hidden" />
|
||||
|
||||
<div className="flex flex-col w-full md:w-1/2 gap-5">
|
||||
<h4 className="text-2xl md:text-3xl font-medium leading-tight">{item.title}</h4>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{item.bullets.map((text, index) => (
|
||||
<Fragment key={index}>
|
||||
<span className="text-base">{text}</span>
|
||||
{index < item.bullets.length - 1 && <span className="text-base text-accent">•</span>}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-2">
|
||||
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />
|
||||
<Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesLabeledList;
|
||||
79
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
79
src/components/sections/features/FeaturesMediaCards.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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) => (
|
||||
<div key={item.title} className="flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 h-full card rounded">
|
||||
<div className="aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
|
||||
<p className="text-sm leading-tight">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</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 bottom-0 left-0 w-full h-1/3 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-linear-to-t from-foreground/60 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-5 p-5">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h3 className="text-2xl md:text-3xl font-medium leading-tight text-background">{item.title}</h3>
|
||||
<p className="text-sm md:text-base leading-tight text-background/75">{item.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
aria-label={item.buttonHref ? `Navigate to ${item.buttonHref}` : "Action button"}
|
||||
className="flex items-center justify-center shrink-0 size-8 cursor-pointer primary-button rounded"
|
||||
>
|
||||
<Icon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesMediaCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesMediaCarouselProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="w-full py-20">
|
||||
<div className="flex flex-col w-full gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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;
|
||||
101
src/components/sections/features/FeaturesProfileCards.tsx
Normal file
101
src/components/sections/features/FeaturesProfileCards.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { BadgeCheck } from "lucide-react";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
avatarSrc: string;
|
||||
buttonText: string;
|
||||
buttonHref?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const FeaturesProfileCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}) => (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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="group relative overflow-hidden aspect-5/6 rounded">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
|
||||
|
||||
<div className="absolute top-5 right-5 z-20">
|
||||
<Button text={item.buttonText} href={item.buttonHref} variant="primary" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
|
||||
<div className="relative flex flex-col gap-1 p-3">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 overflow-hidden rounded secondary-button">
|
||||
<img src={item.avatarSrc} alt="" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold leading-tight truncate text-background transition-colors duration-400 group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<BadgeCheck className="size-5 shrink-0 text-background transition-colors duration-400 group-hover:text-foreground" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default FeaturesProfileCards;
|
||||
102
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
102
src/components/sections/features/FeaturesRevealCards.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<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-5 left-5 z-20 perspective-[1000px]">
|
||||
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden">
|
||||
<span className="text-sm font-medium text-foreground">{index + 1}</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
|
||||
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
|
||||
<div className="relative flex flex-col gap-1 p-3">
|
||||
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
|
||||
|
||||
<h3 className="text-2xl font-semibold leading-tight text-background transition-colors duration-400 group-hover:text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
|
||||
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesRevealCards;
|
||||
87
src/components/sections/features/FeaturesStatisticsCards.tsx
Normal file
87
src/components/sections/features/FeaturesStatisticsCards.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import Button from "@/components/ui/Button";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface FeaturesStatisticsCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesStatisticsCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesStatisticsCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{items.map((item) => (
|
||||
<div key={item.title} className="flex flex-col h-full card rounded">
|
||||
<div className="flex flex-col flex-1 gap-10 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-2xl md:text-3xl font-medium leading-tight truncate">{item.title}</h3>
|
||||
<p className="text-base md:text-lg leading-tight">{item.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-auto">
|
||||
<div className="flex items-center min-w-0 flex-1 gap-2">
|
||||
<span className="shrink-0 size-[1em] rounded-sm bg-accent" />
|
||||
<span className="text-base truncate">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-xl md:text-2xl font-medium">{item.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesStatisticsCards;
|
||||
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 };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesTaggedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesTaggedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesTaggedCardsProps) => {
|
||||
return (
|
||||
<section aria-label="Features section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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-5 h-full group">
|
||||
<div className="relative aspect-square rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
|
||||
<span className="absolute top-5 right-5 px-3 py-1 mb-1 text-sm card rounded">{item.tag}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 flex-1 card rounded">
|
||||
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
|
||||
<p className="text-base leading-tight">{item.description}</p>
|
||||
{(item.primaryButton || item.secondaryButton) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2">
|
||||
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
|
||||
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesTaggedCards;
|
||||
125
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
125
src/components/sections/features/FeaturesTimelineCards.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import Transition from "@/components/ui/Transition";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
interface FeaturesTimelineCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
const FeaturesTimelineCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: FeaturesTimelineCardsProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
|
||||
}, 50);
|
||||
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === 100) {
|
||||
setActiveIndex((i) => (i + 1) % items.length);
|
||||
}
|
||||
}, [progress, items.length]);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (index !== activeIndex) setActiveIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Features timeline section" className="py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto gap-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<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-3 xl:inset-4 2xl:inset-5 overflow-hidden rounded">
|
||||
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div className={cls(
|
||||
"grid grid-cols-1 gap-5",
|
||||
items.length === 2 && "md:grid-cols-2",
|
||||
items.length === 3 && "md:grid-cols-3",
|
||||
items.length >= 4 && "md:grid-cols-4"
|
||||
)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
data-active={index === activeIndex}
|
||||
onClick={() => handleCardClick(index)}
|
||||
className="flex flex-col justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center size-8 primary-button rounded">
|
||||
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
|
||||
</div>
|
||||
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
|
||||
<p className="text-base leading-tight text-balance">{item.description}</p>
|
||||
</div>
|
||||
<div className="relative w-full h-px overflow-hidden">
|
||||
<div className="absolute inset-0 bg-foreground/20" />
|
||||
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesTimelineCards;
|
||||
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">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20" />
|
||||
|
||||
<div className="w-full flex items-center justify-between pt-5">
|
||||
<span className="text-sm opacity-50">{leftText}</span>
|
||||
<span className="text-sm opacity-50">{rightText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBasic;
|
||||
66
src/components/sections/footer/FooterBrand.tsx
Normal file
66
src/components/sections/footer/FooterBrand.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text font-medium hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBrand = ({
|
||||
brand,
|
||||
columns,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
}) => {
|
||||
return (
|
||||
<footer
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 mt-20 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||
<AutoFillText className="font-medium">{brand}</AutoFillText>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||
)}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="flex flex-col items-start gap-3">
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBrand;
|
||||
101
src/components/sections/footer/FooterBrandReveal.tsx
Normal file
101
src/components/sections/footer/FooterBrandReveal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text font-medium hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBrandReveal = ({
|
||||
brand,
|
||||
columns,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
}) => {
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (footerRef.current) {
|
||||
setFooterHeight(footerRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
if (footerRef.current) {
|
||||
resizeObserver.observe(footerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative z-0 w-full mt-20"
|
||||
style={{
|
||||
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fixed bottom-0 w-full"
|
||||
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||
>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
|
||||
<AutoFillText className="font-medium">{brand}</AutoFillText>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"flex flex-col gap-8 mb-10 md:flex-row",
|
||||
columns.length === 1 ? "md:justify-center" : "md:justify-between"
|
||||
)}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={index} className="flex flex-col items-start gap-3">
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBrandReveal;
|
||||
57
src/components/sections/footer/FooterMinimal.tsx
Normal file
57
src/components/sections/footer/FooterMinimal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type SocialLink = {
|
||||
icon: string | LucideIcon;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const SocialLinkItem = ({ icon, href, onClick }: SocialLink) => {
|
||||
const Icon = resolveIcon(icon);
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-center size-10 rounded-full primary-button text-primary-cta-text cursor-pointer"
|
||||
>
|
||||
<Icon className="size-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterMinimal = ({
|
||||
brand,
|
||||
copyright,
|
||||
socialLinks,
|
||||
}: {
|
||||
brand: string;
|
||||
copyright: string;
|
||||
socialLinks?: SocialLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="relative w-full py-20">
|
||||
<div className="flex flex-col w-content-width mx-auto px-10 pb-5 rounded-lg card">
|
||||
<AutoFillText className="font-medium" paddingY="py-5">{brand}</AutoFillText>
|
||||
|
||||
<div className="h-px w-full mb-5 bg-foreground/50" />
|
||||
|
||||
<div className="flex flex-col gap-3 items-center justify-between md:flex-row">
|
||||
<span className="text-base opacity-75">{copyright}</span>
|
||||
{socialLinks && socialLinks.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
{socialLinks.map((link, index) => (
|
||||
<SocialLinkItem key={index} icon={link.icon} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterMinimal;
|
||||
82
src/components/sections/footer/FooterSimple.tsx
Normal file
82
src/components/sections/footer/FooterSimple.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: { label: string; href?: string; onClick?: () => void }[];
|
||||
};
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimple = ({
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="w-full py-15 mt-20 primary-button text-primary-cta-text">
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-medium">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimple;
|
||||
82
src/components/sections/footer/FooterSimpleCard.tsx
Normal file
82
src/components/sections/footer/FooterSimpleCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimpleCard = ({
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="w-full py-20">
|
||||
<div className="w-content-width mx-auto p-10 rounded-lg card">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-medium">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-foreground/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimpleCard;
|
||||
95
src/components/sections/footer/FooterSimpleMedia.tsx
Normal file
95
src/components/sections/footer/FooterSimpleMedia.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: FooterLink[];
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimpleMedia = ({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }) & {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
return (
|
||||
<footer aria-label="Site footer" className="relative w-full mt-20 overflow-hidden">
|
||||
<div className="w-full aspect-square md:aspect-16/6 mask-fade-top-long">
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="w-full h-full object-cover rounded-none!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-15 primary-button text-primary-cta-text">
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-medium">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimpleMedia;
|
||||
120
src/components/sections/footer/FooterSimpleReveal.tsx
Normal file
120
src/components/sections/footer/FooterSimpleReveal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useButtonClick } from "@/hooks/useButtonClick";
|
||||
|
||||
type FooterColumn = {
|
||||
title: string;
|
||||
items: { label: string; href?: string; onClick?: () => void }[];
|
||||
};
|
||||
|
||||
type FooterLink = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
|
||||
const handleClick = useButtonClick(href, onClick);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const FooterSimpleReveal = ({
|
||||
brand,
|
||||
columns,
|
||||
copyright,
|
||||
links,
|
||||
}: {
|
||||
brand: string;
|
||||
columns: FooterColumn[];
|
||||
copyright: string;
|
||||
links: FooterLink[];
|
||||
}) => {
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (footerRef.current) {
|
||||
setFooterHeight(footerRef.current.offsetHeight);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
if (footerRef.current) {
|
||||
resizeObserver.observe(footerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative z-0 w-full mt-20"
|
||||
style={{
|
||||
height: footerHeight ? `${footerHeight}px` : "auto",
|
||||
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="fixed bottom-0 w-full"
|
||||
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
|
||||
>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
aria-label="Site footer"
|
||||
className="w-full py-15 primary-button text-primary-cta-text"
|
||||
>
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
|
||||
<h2 className="text-4xl font-medium">{brand}</h2>
|
||||
|
||||
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
|
||||
{columns.map((column) => (
|
||||
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
|
||||
<h3 className="text-sm opacity-50">{column.title}</h3>
|
||||
{column.items.map((item) => (
|
||||
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
|
||||
<span className="text-sm opacity-50">{copyright}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{links.map((link) => (
|
||||
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterSimpleReveal;
|
||||
70
src/components/sections/hero/HeroBillboard.tsx
Normal file
70
src/components/sections/hero/HeroBillboard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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 };
|
||||
avatars?: { src: string }[];
|
||||
avatarsLabel?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBillboard = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
avatars,
|
||||
avatarsLabel,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBillboardProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:py-30 mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-10 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
{avatars && avatars.length > 0 ? (
|
||||
<AvatarGroup avatars={avatars} label={avatarsLabel} />
|
||||
) : tag ? (
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
) : null}
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 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" delay={0.2} className="w-full p-3 xl:p-4 2xl:p-5 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:py-30 mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-10 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="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl leading-tight text-balance text-right"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardBrand;
|
||||
73
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
73
src/components/sections/hero/HeroBillboardCarousel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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 w-full min-h-svh py-25"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto text-center">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 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-1.5 card rounded-lg overflow-hidden">
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardCarousel;
|
||||
80
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
80
src/components/sections/hero/HeroBillboardScroll.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
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 mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pt-25 pb-20 md:py-30 perspective-distant"
|
||||
>
|
||||
<div className="w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 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-3 card rounded overflow-hidden rotate-x-20 md:hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{ rotateX: rotate, scale }}
|
||||
className="w-content-width mx-auto mt-5 2xl:mt-2 p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden hidden md:block"
|
||||
>
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardScroll;
|
||||
124
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
124
src/components/sections/hero/HeroBillboardTestimonial.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroBillboardTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroBillboardTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroBillboardTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative pt-25 pb-20 md:py-30 mb-20">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col gap-10 w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 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" delay={0.2} className="relative w-full p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute bottom-6 left-6 right-6 md:left-10 md:bottom-10 md:right-auto md:max-w-sm p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-tight text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{testimonial.name}</span>
|
||||
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardTestimonial;
|
||||
59
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
59
src/components/sections/hero/HeroBillboardTiltedCarousel.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import TiltedCarousel from "@/components/ui/TiltedCarousel";
|
||||
|
||||
type HeroBillboardTiltedCarouselProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroBillboardTiltedCarousel = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBillboardTiltedCarouselProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto text-center">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TiltedCarousel items={items} />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBillboardTiltedCarousel;
|
||||
65
src/components/sections/hero/HeroBrand.tsx
Normal file
65
src/components/sections/hero/HeroBrand.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
|
||||
type HeroBrandProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroBrand = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroBrandProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-primary-cta-text leading-tight"
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoFillText className="font-semibold text-primary-cta-text">{brand}</AutoFillText>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBrand;
|
||||
109
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
109
src/components/sections/hero/HeroBrandCarousel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AutoFillText from "@/components/ui/AutoFillText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type HeroBrandCarouselProps = {
|
||||
brand: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const INTERVAL = 4000;
|
||||
|
||||
const HeroBrandCarousel = ({
|
||||
brand,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroBrandCarouselProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % items.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, items.length]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"absolute inset-0 transition-opacity duration-500",
|
||||
currentIndex === index ? "opacity-100 z-1" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
aria-hidden={currentIndex !== index}
|
||||
>
|
||||
<ImageOrVideo
|
||||
imageSrc={item.imageSrc}
|
||||
videoSrc={item.videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-primary-cta-text leading-tight"
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutoFillText className="font-semibold text-primary-cta-text">{brand}</AutoFillText>
|
||||
|
||||
<div className="flex gap-3 pb-5">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="relative h-1 w-full rounded overflow-hidden bg-primary-cta-text/20 cursor-pointer"
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
aria-label="Slide"
|
||||
aria-current={currentIndex === index}
|
||||
>
|
||||
<div
|
||||
className={cls(
|
||||
"absolute inset-0 bg-primary-cta-text rounded origin-left",
|
||||
currentIndex === index ? "animate-progress" : (index < currentIndex ? "scale-x-100" : "scale-x-0")
|
||||
)}
|
||||
style={{ animationDuration: `${INTERVAL}ms` }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroBrandCarousel;
|
||||
79
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
79
src/components/sections/hero/HeroCenteredLogos.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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 AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroCenteredLogosProps = {
|
||||
avatars: { src: string }[];
|
||||
avatarText: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
logos: string[];
|
||||
hideMedia?: boolean;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroCenteredLogos = ({
|
||||
avatars,
|
||||
avatarText,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
logos,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
hideMedia = false,
|
||||
}: HeroCenteredLogosProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative h-svh flex flex-col mb-20">
|
||||
{!hideMedia && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover" />
|
||||
<div className="absolute inset-0 bg-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 pt-8 w-content-width mx-auto text-center">
|
||||
<AvatarGroup avatars={avatars} label={avatarText} size="lg" />
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="md:max-w-8/10 text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="relative z-10 w-content-width mx-auto pb-8 overflow-hidden mask-fade-x">
|
||||
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
|
||||
{[...logos, ...logos, ...logos, ...logos].map((logo, index) => (
|
||||
<div key={index} className="shrink-0 mx-3 px-4 py-2 rounded card">
|
||||
<span className="text-xl font-semibold whitespace-nowrap opacity-75">{logo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroCenteredLogos;
|
||||
81
src/components/sections/hero/HeroOverlay.tsx
Normal file
81
src/components/sections/hero/HeroOverlay.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import AvatarGroup from "@/components/ui/AvatarGroup";
|
||||
|
||||
type HeroOverlayProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
avatars?: { src: string }[];
|
||||
avatarsLabel?: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroOverlay = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
avatars,
|
||||
avatarsLabel,
|
||||
}: HeroOverlayProps) => {
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-[150vw] h-[150vw] left-0 bottom-0 -translate-x-1/2 translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pb-10 md:pb-25">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<span className="w-fit px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-primary-cta-text text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl text-primary-cta-text leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
|
||||
{avatars && avatars.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<AvatarGroup avatars={avatars} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlay;
|
||||
133
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
133
src/components/sections/hero/HeroOverlayTestimonial.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroOverlayTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroOverlayTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroOverlayTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Hero section"
|
||||
className="relative w-full h-svh overflow-hidden flex flex-col justify-start mb-20"
|
||||
>
|
||||
<HeroBackgroundSlot />
|
||||
<ImageOrVideo
|
||||
imageSrc={imageSrc}
|
||||
videoSrc={videoSrc}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-content-width mx-auto pt-35">
|
||||
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
|
||||
<span className="w-fit px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-primary-cta-text text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-lg md:text-xl text-primary-cta-text leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute z-10 bottom-3 left-3 right-3 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5 md:left-auto md:bottom-8 md:right-8 md:max-w-25/100 2xl:max-w-2/10"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-tight text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{testimonial.name}</span>
|
||||
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroOverlayTestimonial;
|
||||
63
src/components/sections/hero/HeroSplit.tsx
Normal file
63
src/components/sections/hero/HeroSplit.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroSplitProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const HeroSplit = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
}: HeroSplitProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplit;
|
||||
129
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
129
src/components/sections/hero/HeroSplitKpi.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type KpiItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type HeroSplitKpiProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
kpis: [KpiItem, KpiItem, KpiItem];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const KPI_POSITIONS = ["top-[5%] left-0", "top-[40%] right-0", "bottom-[5%] left-[5%]"];
|
||||
|
||||
const HeroSplitKpi = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
kpis,
|
||||
}: HeroSplitKpiProps) => {
|
||||
const kpiRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth <= 768) return;
|
||||
|
||||
let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
const offsets = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
|
||||
const multipliers = [-0.25, -0.5, 0.25];
|
||||
let animationId: number;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouseX = (e.clientX / window.innerWidth) * 100 - 50;
|
||||
mouseY = (e.clientY / window.innerHeight) * 100 - 50;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
offsets.forEach((offset, i) => {
|
||||
offset.x += ((mouseX * multipliers[i]) - offset.x) * 0.025;
|
||||
offset.y += ((mouseY * multipliers[i]) - offset.y) * 0.025;
|
||||
|
||||
const el = kpiRefs.current[i];
|
||||
if (el) el.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
|
||||
});
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
|
||||
<ScrollReveal variant="slide-up" delay={0.2} className="w-full h-full p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden scale-80">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
</ScrollReveal>
|
||||
|
||||
{kpis.map((kpi, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
ref={(el) => { kpiRefs.current[index] = el; }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut", delay: 0.4 + index * 0.1 }}
|
||||
className={cls(
|
||||
"absolute flex flex-col items-center p-3 xl:p-4 2xl:p-5 card backdrop-blur-sm rounded",
|
||||
KPI_POSITIONS[index]
|
||||
)}
|
||||
>
|
||||
<p className="text-2xl md:text-4xl font-medium">{kpi.value}</p>
|
||||
<p className="text-sm md:text-base text-foreground/70">{kpi.label}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitKpi;
|
||||
70
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
70
src/components/sections/hero/HeroSplitMediaGrid.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type HeroSplitMediaGridProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: [
|
||||
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never },
|
||||
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }
|
||||
];
|
||||
};
|
||||
|
||||
const HeroSplitMediaGrid = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroSplitMediaGridProps) => {
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full md:w-1/2 grid grid-cols-2 gap-3">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="h-80 md:h-[55vh] p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitMediaGrid;
|
||||
126
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
126
src/components/sections/hero/HeroSplitTestimonial.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Testimonial = {
|
||||
name: string;
|
||||
handle: string;
|
||||
text: string;
|
||||
rating: number;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
type HeroSplitTestimonialProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
testimonials: Testimonial[];
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
const HeroSplitTestimonial = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
testimonials,
|
||||
}: HeroSplitTestimonialProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
|
||||
}, INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentIndex, testimonials.length]);
|
||||
|
||||
const testimonial = testimonials[currentIndex];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-3 xl:p-4 2xl:p-5 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute bottom-6 left-6 right-6 md:left-auto md:max-w-5/10 p-3 xl:p-4 2xl:p-5 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg leading-tight text-balance">{testimonial.text}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageOrVideo
|
||||
imageSrc={testimonial.imageSrc}
|
||||
videoSrc={testimonial.videoSrc}
|
||||
className="size-10 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{testimonial.name}</span>
|
||||
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitTestimonial;
|
||||
85
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
85
src/components/sections/hero/HeroSplitVerticalMarquee.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
|
||||
type HeroSplitVerticalMarqueeProps = {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
};
|
||||
|
||||
const HeroSplitVerticalMarquee = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
leftItems,
|
||||
rightItems,
|
||||
}: HeroSplitVerticalMarqueeProps) => {
|
||||
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
|
||||
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col items-center md:items-start gap-3">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
|
||||
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
|
||||
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-3 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col gap-3 animate-marquee-vertical">
|
||||
{duplicatedLeft.map((item, index) => (
|
||||
<div key={index} className="shrink-0 aspect-square p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden mask-fade-y">
|
||||
<div className="flex flex-col gap-3 animate-marquee-vertical-reverse">
|
||||
{duplicatedRight.map((item, index) => (
|
||||
<div key={index} className="shrink-0 aspect-square p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSplitVerticalMarquee;
|
||||
93
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
93
src/components/sections/hero/HeroTiltedCards.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface HeroTiltedCardsProps {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton: { text: string; href: string };
|
||||
secondaryButton: { text: string; href: string };
|
||||
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
|
||||
}
|
||||
|
||||
const HeroTiltedCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
items,
|
||||
}: HeroTiltedCardsProps) => {
|
||||
const marqueeItems = [...items, ...items];
|
||||
const galleryStyles = [
|
||||
"-rotate-6 z-10 -translate-y-5",
|
||||
"rotate-6 z-20 translate-y-5 -ml-15",
|
||||
"-rotate-6 z-30 -translate-y-5 -ml-15",
|
||||
"rotate-6 z-40 translate-y-5 -ml-15",
|
||||
"-rotate-6 z-50 -translate-y-5 -ml-15",
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
|
||||
<HeroBackgroundSlot />
|
||||
<div className="flex flex-col items-center gap-10 md:gap-15 w-full md:w-content-width mx-auto">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto text-center">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="slide-up"
|
||||
gradientText={true}
|
||||
tag="h1"
|
||||
className="text-6xl font-medium text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="slide-up"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="text-base md:text-lg leading-tight text-balance"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3 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="block md:hidden w-full overflow-hidden mask-padding-x">
|
||||
<div className="flex w-max animate-marquee-horizontal">
|
||||
{marqueeItems.map((item, index) => (
|
||||
<div key={index} className="shrink-0 w-[50vw] mr-5 aspect-4/5 p-2 card rounded overflow-hidden">
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal variant="fade-blur" delay={0.2} className="hidden md:flex justify-center items-center w-full">
|
||||
<div className="flex items-center justify-center">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cls(
|
||||
"relative w-[23%] aspect-4/5 p-2 card rounded overflow-hidden shadow-lg transition-transform duration-500 ease-out hover:scale-110",
|
||||
galleryStyles[index]
|
||||
)}
|
||||
>
|
||||
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroTiltedCards;
|
||||
71
src/components/sections/legal/PolicyContent.tsx
Normal file
71
src/components/sections/legal/PolicyContent.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
type ContentItem =
|
||||
| { type: "paragraph"; text: string }
|
||||
| { type: "list"; items: string[] }
|
||||
| { type: "numbered-list"; items: string[] };
|
||||
|
||||
type ContentSection = {
|
||||
heading: string;
|
||||
content: ContentItem[];
|
||||
};
|
||||
|
||||
const PolicyContent = ({
|
||||
title,
|
||||
subtitle,
|
||||
sections,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sections: ContentSection[];
|
||||
}) => {
|
||||
return (
|
||||
<article aria-label="Policy content" className="w-content-width mx-auto pt-40 pb-20">
|
||||
<div className="md:max-w-1/2 mx-auto flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-3xl md:text-4xl font-medium leading-tight">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm opacity-50">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-foreground/20" />
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{sections.map((section) => (
|
||||
<section key={section.heading} className="flex flex-col gap-3">
|
||||
<h2 className="text-xl md:text-2xl font-medium leading-tight">{section.heading}</h2>
|
||||
{section.content.map((item, i) => {
|
||||
if (item.type === "paragraph") {
|
||||
return (
|
||||
<p key={i} className="text-sm md:text-base opacity-75 leading-relaxed">
|
||||
{item.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const ListTag = item.type === "numbered-list" ? "ol" : "ul";
|
||||
|
||||
return (
|
||||
<ListTag
|
||||
key={i}
|
||||
className={cls(
|
||||
"flex flex-col gap-3 pl-5 text-sm md:text-base opacity-75 leading-relaxed",
|
||||
item.type === "numbered-list" ? "list-decimal" : "list-disc"
|
||||
)}
|
||||
>
|
||||
{item.items.map((li, j) => (
|
||||
<li key={j}>{li}</li>
|
||||
))}
|
||||
</ListTag>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyContent;
|
||||
84
src/components/sections/metrics/MetricsFeatureCards.tsx
Normal file
84
src/components/sections/metrics/MetricsFeatureCards.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Check } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
title: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const MetricsFeatureCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="slide-up">
|
||||
<GridOrCarousel>
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.value} className="flex flex-col justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 h-full card rounded">
|
||||
<div className="flex flex-col gap-0">
|
||||
<span className="text-8xl md:text-7xl font-medium leading-none truncate">{metric.value}</span>
|
||||
<span className="text-xl truncate">{metric.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-5 border-t border-accent">
|
||||
{metric.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
|
||||
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm leading-tight">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsFeatureCards;
|
||||
90
src/components/sections/metrics/MetricsGradientCards.tsx
Normal file
90
src/components/sections/metrics/MetricsGradientCards.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 GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string | LucideIcon;
|
||||
};
|
||||
|
||||
const MetricsGradientCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade-blur"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade-blur"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-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>
|
||||
{metrics.map((metric) => {
|
||||
const IconComponent = resolveIcon(metric.icon);
|
||||
return (
|
||||
<div key={metric.value} className="relative flex flex-col items-center justify-center gap-0 p-3 xl:p-4 2xl:p-5 min-h-70 h-full card rounded">
|
||||
<span
|
||||
className="text-9xl font-medium leading-none text-center truncate"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(to bottom, var(--color-foreground) 0%, var(--color-foreground) 20%, transparent 72%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{metric.value}
|
||||
</span>
|
||||
<span className="mt-[-0.75em] text-4xl font-medium text-center truncate">{metric.title}</span>
|
||||
<p className="max-w-9/10 md:max-w-7/10 mt-2 text-base leading-tight text-center line-clamp-2">{metric.description}</p>
|
||||
<div className="absolute bottom-5 left-5 flex items-center justify-center size-10 primary-button rounded">
|
||||
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsGradientCards;
|
||||
80
src/components/sections/metrics/MetricsIconCards.tsx
Normal file
80
src/components/sections/metrics/MetricsIconCards.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import GridOrCarousel from "@/components/ui/GridOrCarousel";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
import { resolveIcon } from "@/utils/resolve-icon";
|
||||
|
||||
type Metric = {
|
||||
icon: string | LucideIcon;
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const MetricsIconCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<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-tight 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>
|
||||
|
||||
<ScrollReveal variant="fade-blur">
|
||||
<GridOrCarousel>
|
||||
{metrics.map((metric) => {
|
||||
const IconComponent = resolveIcon(metric.icon);
|
||||
return (
|
||||
<div key={metric.value} className="flex flex-col items-center justify-center gap-3 p-3 xl:p-4 2xl:p-5 min-h-70 h-full card rounded">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center size-8 primary-button rounded">
|
||||
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-xl truncate">{metric.title}</span>
|
||||
</div>
|
||||
<span className="text-7xl font-medium leading-none truncate">{metric.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</GridOrCarousel>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsIconCards;
|
||||
98
src/components/sections/metrics/MetricsMediaCards.tsx
Normal file
98
src/components/sections/metrics/MetricsMediaCards.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { cls } from "@/lib/utils";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextAnimation from "@/components/ui/TextAnimation";
|
||||
import ImageOrVideo from "@/components/ui/ImageOrVideo";
|
||||
import ScrollReveal from "@/components/ui/ScrollReveal";
|
||||
|
||||
type Metric = {
|
||||
value: string;
|
||||
title: string;
|
||||
description: string;
|
||||
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
|
||||
|
||||
const MetricsMediaCards = ({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
metrics,
|
||||
}: {
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: { text: string; href: string };
|
||||
secondaryButton?: { text: string; href: string };
|
||||
metrics: Metric[];
|
||||
}) => (
|
||||
<section aria-label="Metrics section" className="py-20">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
|
||||
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
|
||||
|
||||
<TextAnimation
|
||||
text={title}
|
||||
variant="fade"
|
||||
gradientText={true}
|
||||
tag="h2"
|
||||
className="text-6xl font-medium text-center text-balance"
|
||||
/>
|
||||
|
||||
<TextAnimation
|
||||
text={description}
|
||||
variant="fade"
|
||||
gradientText={false}
|
||||
tag="p"
|
||||
className="md:max-w-6/10 text-lg leading-tight text-center"
|
||||
/>
|
||||
|
||||
{(primaryButton || secondaryButton) && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-3">
|
||||
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
|
||||
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 w-content-width mx-auto">
|
||||
{metrics.map((metric, index) => {
|
||||
const isEven = index % 2 === 1;
|
||||
const isLast = index === metrics.length - 1;
|
||||
const isOddTotal = metrics.length % 2 !== 0;
|
||||
const shouldSpanFull = isLast && isOddTotal;
|
||||
|
||||
return (
|
||||
<ScrollReveal
|
||||
variant="fade"
|
||||
key={metric.value}
|
||||
className={cls("grid grid-cols-2 gap-5", shouldSpanFull && "md:col-span-2")}
|
||||
>
|
||||
<div className={cls(
|
||||
"flex flex-col justify-between gap-3 xl:gap-4 2xl:gap-5 p-3 xl:p-4 2xl:p-5 card rounded",
|
||||
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
|
||||
isEven && "order-2 md:order-1"
|
||||
)}>
|
||||
<span className="text-5xl md:text-6xl font-medium leading-tight truncate">{metric.value}</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl md:text-2xl font-medium truncate">{metric.title}</span>
|
||||
<div className="w-full h-px bg-accent" />
|
||||
<p className="text-base leading-tight truncate">{metric.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cls(
|
||||
"rounded overflow-hidden",
|
||||
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
|
||||
isEven && "order-1 md:order-2"
|
||||
)}>
|
||||
<ImageOrVideo imageSrc={metric.imageSrc} videoSrc={metric.videoSrc} />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default MetricsMediaCards;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user