Initial commit

This commit is contained in:
vitalijmulika
2026-04-21 14:19:45 +03:00
commit e6fe83ee2d
179 changed files with 21453 additions and 0 deletions

View 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

View 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
View 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
View 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
View 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)]" />
```

View 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

View 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.

View 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.

View 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

View 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.

View 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.

View 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.

View 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
View File

@@ -0,0 +1,4 @@
VITE_API_URL=http://localhost:3001
VITE_PROJECT_ID=603db9a1-9ddb-42e3-8dcf-75f49f1e0197

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# API Configuration
VITE_API_URL=
VITE_PROJECT_ID=

View 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
View 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
View 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
View File

@@ -0,0 +1,218 @@
# AI-Friendly Component Library
## The Problem
Current architecture is optimized for human developers (DRY, reusable, flexible) but this makes AI editing fail. AI can't trace through ThemeProvider indirection, 40+ prop interfaces, nested component layers, and custom Tailwind scales. It hallucinates because it can't see the actual output.
## The Solution
Shift from human-optimized to AI-optimized code:
- **Explicit over implicit** - All styles visible in file, no ThemeProvider magic
- **Flat over nested** - Direct components, no wrapper layers to trace through
- **Concrete over abstract** - Actual Tailwind classes, not prop names that map to hidden styles
- **Standard over custom** - Default Tailwind values AI already knows
- **Simple over flexible** - One way to do things, not 11 variants
Components become templates that AI reads, understands, and edits directly.
---
## 1. Backend-Driven Sections
Generated website repository only contains what is used, plus general UI components. Section components are stored on the backend and injected when needed, not bundled in the boilerplate.
**Alternative:** If backend approach takes too long, keep all sections in the boilerplate but with the new simplified structure defined below.
---
## 2. Props & Structure
Remove all styling/className props. AI edits classes directly in the component file.
**Option A - Minimal props:** Keep text, links, assets, arrays, icons as props. Backend passes content, AI edits props in page file.
**Option B - No props:** All content hardcoded in section file. Backend injects entire file with content baked in. AI edits section file directly. Maximum explicitness.
**Flat structure:** Sections use direct components (TextAnimation, Button, MediaContent) not wrapper components like TextBox that nest other components inside.
---
## 3. Framer Motion for Element Animations
Replace GSAP (ScrollTrigger, `gsap.context()`, `ctx.revert()`) with Framer Motion `motion` divs for cards, buttons, tags, and other elements. Use `whileInView` for scroll-triggered animations.
---
## 4. Simplified Tailwind & Single CSS File
Remove all custom Tailwind values (spacing, widths, gaps). Use default Tailwind classes.
**Keep:**
- Content width approach (for page width and carousels)
- Fluid font sizes (AI picks bad font sizes otherwise)
**One globals.css file with:**
- Color variables in `:root`
- `--radius` variable, applied globally via `@layer base`
- Card styles
- Button styles
- Utility classes (masks, animations) - consolidated, fewer variants
- Base styling (body defaults, scrollbar, heading font-family)
AI picks from a reference list of available styles and writes them directly to globals.css.
---
## 5. Remove ThemeProvider
Delete the entire ThemeProvider system. No dynamic style injection.
**Card/button styles:** AI chooses on backend, adds one card style, one primary button style, one secondary button style to globals.css.
**Text animations:** Remove TextBox component. Use simple `TextAnimation` component in section files with `text` and `animationType` props.
**Button:** Single `components/ui/Button.tsx` with `text`, `href`, and `className` (e.g. `primary-button` defined in globals.css).
**Content width:** Fixed value in globals.css.
**Text sizing:** Fixed values in globals.css. Future: AI picks from 3 presets on backend and adds to globals.css.
**Background components:** Remove all (aurora, grid, floatingGradient, etc.).
**Fonts:** Keep current logic.
---
## 6. New Folder Structure
```
src/
├── app/ # Pages
├── components/
│ ├── ui/ # Atomic UI components
│ └── [sections] # Section components flat, not nested (injected from backend)
├── hooks/ # Common hooks
├── lib/ # Utilities
└── styles/
└── globals.css # Single CSS file
```
Remove: `providers/themeProvider/`, multiple CSS files, `button/` variants folder, `text/` folder, `background/` folder.
---
## 7. Atomic UI Components
Add simple, single-purpose components to `components/ui/`:
Button, TextAnimation, MediaContent, Toggle, Dropdown, Accordion, Avatar, Badge, Breadcrumb, Calendar, Chart, Checkbox, Form, Input, Label, Tooltip
Expand as needed.
---
## 8. Simplify Complex Components
**Navbar:** Separate navbar components instead of one with variants. AI chooses which one. Reduce to 4 navbars for now.
**CardStack → GridLayout:** Replace complex CardStack (mode, gridVariant, carouselThreshold, ~40 props) with simple wrapper. Children as items. Single prop: items per row (3 or 4). Under = grid, over = carousel. Carousel mode keeps controls (prev/next, progress bar). No title/description/animation props - sections handle all that directly. Remove timelines, grid configs, auto-carousel variants for now, refactor later.
**TextBox:** Remove completely. Use TextAnimation and Button directly in sections.
---
## 9. Consistent File Patterns
Every section file follows the EXACT same structure. AI sees one section, knows how all work.
**Template:**
```
1. IMPORTS (same order: react, framer-motion, ui components, icons)
2. CONTENT (variables at top if Option B)
3. COMPONENT (predictable JSX order)
4. EXPORT (always at bottom)
```
**JSX order:** tag → title → description → buttons → media (always same sequence)
---
## 10. Short Files, No Sub-components
**Line limits:**
- Sections: under 100 lines
- UI components: under 50 lines
**Key rule:** Don't split into sub-components to achieve this. Sub-components add nesting/indirection. Instead, simplify the section itself.
If a section is too long, it's too complex. Simplify the design, don't extract parts.
---
## 11. Content at Top
If using Option B (no props), put all editable content as variables at the very top of the file.
AI immediately knows: "edit content? check the top."
---
## 12. Remove Complex Components
Remove overly complex components for now. Add back simplified versions later if needed.
---
## 13. CSS Class Organization
**Consistent class order:** layout → spacing → sizing → typography → colors → effects
**Keep className short.** Too many classes = section too complex. Use global CSS (`.card`, `.btn-primary`) for repeated patterns.
**No dynamic classes in sections.** Avoid `cls(condition && "class")`. Keep it explicit.
**One line if short, split by category if long.**
---
## Implementation Roadmap
### Phase 1: Foundation (Delete)
- Delete `providers/themeProvider/` (13 files)
- Delete `components/background/` (27 files)
- Delete `components/Textbox.tsx`
- Delete `components/text/` (GSAP TextAnimation)
- Consolidate all CSS → single `styles/globals.css`
### Phase 2: Core Simplification
- Button → single `ui/Button.tsx` (50 lines max)
- Navbar → 4 separate components, no animation hooks
- TextAnimation → new Framer Motion version
- CardStack → simple GridLayout wrapper
### Phase 3: Section Refactoring
- Remove all className/containerClassName props
- Remove useTheme() calls
- Each section under 100 lines
- Content variables at top (if Option B)
- Flat structure, no nested wrappers
### Phase 4: Organize
- Create `components/ui/` with atomic components
- Move hooks to `hooks/` folder
- Update all imports
---
## Current → Target
| Metric | Current | Target |
|--------|---------|--------|
| Total files | ~270 | ~100 |
| Lines of code | ~25,000 | ~10,000 |
| Props per section | 20-40 | 5-10 |
| CSS files | 37 | 1 |
| Button variants | 11 | 1 |
| Navbar variants | 5 | 4 |
---

View File

@@ -0,0 +1,58 @@
================================================================================
THEME OPTIONS (v4)
================================================================================
STYLE PROPS
-----------
1. borderRadius
• "rounded"
• "soft"
• "pill"
2. contentWidth
• "small"
• "compact"
• "medium"
• "mediumLarge"
3. cardStyle
• "solid"
• "outline"
• "gradient-mesh"
• "gradient-radial"
• "inset"
• "glass-elevated"
• "glass-depth"
• "gradient-bordered"
• "layered-gradient"
• "soft-shadow"
• "subtle-shadow"
• "elevated-border"
• "inner-glow"
• "spotlight"
4. primaryButtonStyle
• "gradient"
• "shadow"
• "flat"
• "radial-glow"
• "diagonal-gradient"
• "double-inset"
• "primary-glow"
• "inset-glow"
• "soft-glow"
• "glass-shimmer"
• "neon-outline"
• "lifted"
• "depth-layers"
• "accent-edge"
• "metallic"
5. secondaryButtonStyle
• "glass"
• "solid"
• "layered"
• "radial-glow"
================================================================================

677
colorThemes.json Normal file
View File

@@ -0,0 +1,677 @@
{
"lightTheme": {
"minimalDarkBlue": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#000612e6",
"--primary-cta": "#15479c",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#c4c4c4",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#000612e6"
},
"minimalDarkGreen": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#000f06e6",
"--primary-cta": "#0a7039",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#c4c4c4",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#000f06e6"
},
"minimalLightRed": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#120006e6",
"--primary-cta": "#e63946",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#c4c4c4",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#120006e6"
},
"minimalBrightBlue": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#000612e6",
"--primary-cta": "#106EFB",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#106EFB",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#000612e6"
},
"minimalBrightOrange": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#120a00e6",
"--primary-cta": "#E34400",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#E34400",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#120a00e6"
},
"minimalGoldenOrange": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#120a00e6",
"--primary-cta": "#FF7B05",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#FF7B05",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#120a00e6"
},
"minimalLightOrange": {
"--background": "#ffffff",
"--card": "#f9f9f9",
"--foreground": "#120a00e6",
"--primary-cta": "#ff8c42",
"--secondary-cta": "#f9f9f9",
"--accent": "#e2e2e2",
"--background-accent": "#c4c4c4",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#120a00e6"
},
"darkBlue": {
"--background": "#f5faff",
"--card": "#f1f8ff",
"--foreground": "#001122",
"--primary-cta": "#15479c",
"--secondary-cta": "#ffffff",
"--accent": "#a8cce8",
"--background-accent": "#7ba3cf",
"--primary-cta-text": "#f5faff",
"--secondary-cta-text": "#001122"
},
"darkGreen": {
"--background": "#fafffb",
"--card": "#f7fffa",
"--foreground": "#001a0a",
"--primary-cta": "#0a7039",
"--secondary-cta": "#ffffff",
"--accent": "#a8d9be",
"--background-accent": "#6bbf8e",
"--primary-cta-text": "#fafffb",
"--secondary-cta-text": "#001a0a"
},
"lightRed": {
"--background": "#fffafa",
"--card": "#fff7f7",
"--foreground": "#1a0000",
"--primary-cta": "#e63946",
"--secondary-cta": "#ffffff",
"--accent": "#f5c4c7",
"--background-accent": "#f09199",
"--primary-cta-text": "#fffafa",
"--secondary-cta-text": "#1a0000"
},
"lightPurple": {
"--background": "#fbfaff",
"--card": "#f7f5ff",
"--foreground": "#0f0022",
"--primary-cta": "#8b5cf6",
"--secondary-cta": "#ffffff",
"--accent": "#d8cef5",
"--background-accent": "#c4a8f9",
"--primary-cta-text": "#fbfaff",
"--secondary-cta-text": "#0f0022"
},
"warmCream": {
"--background": "#f6f0e9",
"--card": "#efe7dd",
"--foreground": "#2b180a",
"--primary-cta": "#2b180a",
"--secondary-cta": "#efe7dd",
"--accent": "#94877c",
"--background-accent": "#afa094",
"--primary-cta-text": "#f6f0e9",
"--secondary-cta-text": "#2b180a"
},
"grayBlueAccent": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#1c1c1c",
"--secondary-cta": "#ffffff",
"--accent": "#15479c",
"--background-accent": "#a8cce8",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"grayGreenAccent": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#1c1c1c",
"--secondary-cta": "#ffffff",
"--accent": "#159c49",
"--background-accent": "#a8e8ba",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"grayRedAccent": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#1c1c1c",
"--secondary-cta": "#ffffff",
"--accent": "#e63946",
"--background-accent": "#e8bea8",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"grayPurpleAccent": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#1c1c1c",
"--secondary-cta": "#ffffff",
"--accent": "#6139e6",
"--background-accent": "#b3a8e8",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"warmBeige": {
"--background": "#efebe5",
"--card": "#f7f2ea",
"--foreground": "#000000",
"--primary-cta": "#000000",
"--secondary-cta": "#ffffff",
"--accent": "#ffffff",
"--background-accent": "#e1b875",
"--primary-cta-text": "#efebe5",
"--secondary-cta-text": "#000000"
},
"grayTealGreen": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#1f514c",
"--secondary-cta": "#ffffff",
"--accent": "#159c49",
"--background-accent": "#a8e8ba",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"grayNavyBlue": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#1f3251",
"--secondary-cta": "#ffffff",
"--accent": "#15479c",
"--background-accent": "#a8cce8",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"grayBurgundyRed": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#511f1f",
"--secondary-cta": "#ffffff",
"--accent": "#e63946",
"--background-accent": "#e8bea8",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"grayIndigoPurple": {
"--background": "#f5f5f5",
"--card": "#ffffff",
"--foreground": "#1c1c1c",
"--primary-cta": "#341f51",
"--secondary-cta": "#ffffff",
"--accent": "#6139e6",
"--background-accent": "#b3a8e8",
"--primary-cta-text": "#f5f5f5",
"--secondary-cta-text": "#1c1c1c"
},
"warmgrayPink": {
"--background": "#f7f6f7",
"--card": "#ffffff",
"--foreground": "#1b0c25",
"--primary-cta": "#1b0c25",
"--secondary-cta": "#ffffff",
"--accent": "#ff93e4",
"--background-accent": "#e8a8c3",
"--primary-cta-text": "#f7f6f7",
"--secondary-cta-text": "#1b0c25"
},
"warmgrayOrange": {
"--background": "#f7f6f7",
"--card": "#ffffff",
"--foreground": "#25190c",
"--primary-cta": "#ff6207",
"--secondary-cta": "#ffffff",
"--accent": "#ffce93",
"--background-accent": "#e8cfa8",
"--primary-cta-text": "#f7f6f7",
"--secondary-cta-text": "#25190c"
},
"warmgrayBlue": {
"--background": "#f7f6f7",
"--card": "#ffffff",
"--foreground": "#0c1325",
"--primary-cta": "#0798ff",
"--secondary-cta": "#ffffff",
"--accent": "#93c7ff",
"--background-accent": "#a8cde8",
"--primary-cta-text": "#f7f6f7",
"--secondary-cta-text": "#0c1325"
},
"warmgrayIndigo": {
"--background": "#f7f6f7",
"--card": "#ffffff",
"--foreground": "#0c1325",
"--primary-cta": "#0b07ff",
"--secondary-cta": "#ffffff",
"--accent": "#93b7ff",
"--background-accent": "#a8bae8",
"--primary-cta-text": "#f7f6f7",
"--secondary-cta-text": "#0c1325"
},
"lavenderPeach": {
"--background": "#e3deea",
"--card": "#ffffff",
"--foreground": "#27231f",
"--primary-cta": "#27231f",
"--secondary-cta": "#ffffff",
"--accent": "#c68a62",
"--background-accent": "#c68a62",
"--primary-cta-text": "#e3deea",
"--secondary-cta-text": "#27231f"
},
"lavenderBlue": {
"--background": "#e3deea",
"--card": "#ffffff",
"--foreground": "#1f2027",
"--primary-cta": "#1f2027",
"--secondary-cta": "#ffffff",
"--accent": "#627dc6",
"--background-accent": "#627dc6",
"--primary-cta-text": "#e3deea",
"--secondary-cta-text": "#1f2027"
},
"warmStone": {
"--background": "#f5f4ef",
"--card": "#dad6cd",
"--foreground": "#2a2928",
"--primary-cta": "#2a2928",
"--secondary-cta": "#ecebea",
"--accent": "#ffffff",
"--background-accent": "#c6b180",
"--primary-cta-text": "#f5f4ef",
"--secondary-cta-text": "#2a2928"
},
"warmStoneGray": {
"--background": "#f5f4f0",
"--card": "#ffffff",
"--foreground": "#1a1a1a",
"--primary-cta": "#2c2c2c",
"--secondary-cta": "#f5f4f0",
"--accent": "#8a8a8a",
"--background-accent": "#e8e6e1",
"--primary-cta-text": "#f5f4f0",
"--secondary-cta-text": "#1a1a1a"
},
"warmGreen": {
"--background": "#fffefe",
"--card": "#f6f7f4",
"--foreground": "#080908",
"--primary-cta": "#0e3a29",
"--secondary-cta": "#e7eecd",
"--accent": "#35c18b",
"--background-accent": "#ecebe4",
"--primary-cta-text": "#fffefe",
"--secondary-cta-text": "#080908"
},
"warmSand": {
"--background": "#fcf6ec",
"--card": "#f3ede2",
"--foreground": "#2e2521",
"--primary-cta": "#2e2521",
"--secondary-cta": "#ffffff",
"--accent": "#b2a28b",
"--background-accent": "#b2a28b",
"--primary-cta-text": "#fcf6ec",
"--secondary-cta-text": "#2e2521"
},
"warmgrayRed": {
"--background": "#f7f6f7",
"--card": "#ffffff",
"--foreground": "#250c0d",
"--primary-cta": "#b82b40",
"--secondary-cta": "#ffffff",
"--accent": "#b90941",
"--background-accent": "#e8a8b6",
"--primary-cta-text": "#f7f6f7",
"--secondary-cta-text": "#250c0d"
}
},
"darkTheme": {
"minimal": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#ffffffe6",
"--primary-cta": "#e6e6e6",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#ffffffe6"
},
"minimalLightBlue": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#f0f8ffe6",
"--primary-cta": "#cee7ff",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#f0f8ffe6"
},
"minimalLightGreen": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#f5fffae6",
"--primary-cta": "#80da9b",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#f5fffae6"
},
"minimalLightRed": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#fff5f5e6",
"--primary-cta": "#ff7a7a",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#fff5f5e6"
},
"minimalLightPurple": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#f8f5ffe6",
"--primary-cta": "#c89bff",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#f8f5ffe6"
},
"lightBlueWhite": {
"--background": "#010912",
"--card": "#152840",
"--foreground": "#e6f0ff",
"--primary-cta": "#cee7ff",
"--secondary-cta": "#0e1a29",
"--accent": "#3f5c79",
"--background-accent": "#004a93",
"--primary-cta-text": "#010912",
"--secondary-cta-text": "#ffffff"
},
"lime": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#f5f5f5",
"--primary-cta": "#dfff1c",
"--secondary-cta": "#1a1a1a",
"--accent": "#8b9a1b",
"--background-accent": "#5d6b00",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#ffffff"
},
"gold": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#f5f5f5",
"--primary-cta": "#ffdf7d",
"--secondary-cta": "#1a1a1a",
"--accent": "#b8860b",
"--background-accent": "#8b6914",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#ffffff"
},
"crimson": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#f5f5f5",
"--primary-cta": "#ff0000",
"--secondary-cta": "#1a1a1a",
"--accent": "#991b1b",
"--background-accent": "#7f1d1d",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#ffffff"
},
"midnightIce": {
"--background": "#000000",
"--card": "#0c0c0c",
"--foreground": "#ffffff",
"--primary-cta": "#cee7ff",
"--secondary-cta": "#000000",
"--accent": "#535353",
"--background-accent": "#CEE7FF",
"--primary-cta-text": "#000000",
"--secondary-cta-text": "#ffffff"
},
"midnightBlue": {
"--background": "#000000",
"--card": "#0c0c0c",
"--foreground": "#ffffff",
"--primary-cta": "#106EFB",
"--secondary-cta": "#000000",
"--accent": "#535353",
"--background-accent": "#106EFB",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#ffffff"
},
"blueOrangeAccent": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#ffffff",
"--primary-cta": "#1f7cff",
"--secondary-cta": "#010101",
"--accent": "#1f7cff",
"--background-accent": "#f96b2f",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#ffffff"
},
"orangeBlueAccent": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#ffffff",
"--primary-cta": "#e34400",
"--secondary-cta": "#010101",
"--accent": "#ff7b05",
"--background-accent": "#106efb",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#ffffff"
},
"minimalBrightOrange": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#ffffff",
"--primary-cta": "#e34400",
"--secondary-cta": "#010101",
"--accent": "#737373",
"--background-accent": "#e34400",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#ffffff"
},
"minimalLightOrange": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#fffaf5e6",
"--primary-cta": "#ffaa70",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#fffaf5e6"
},
"minimalLightYellow": {
"--background": "#0a0a0a",
"--card": "#1a1a1a",
"--foreground": "#fffffae6",
"--primary-cta": "#fde047",
"--secondary-cta": "#1a1a1a",
"--accent": "#737373",
"--background-accent": "#737373",
"--primary-cta-text": "#0a0a0a",
"--secondary-cta-text": "#fffffae6"
},
"lightBlue": {
"--background": "#010912",
"--card": "#152840",
"--foreground": "#e6f0ff",
"--primary-cta": "#cee7ff",
"--secondary-cta": "#0e1a29",
"--accent": "#3f5c79",
"--background-accent": "#004a93",
"--primary-cta-text": "#010912",
"--secondary-cta-text": "#e6f0ff"
},
"lightGreen": {
"--background": "#000802",
"--card": "#0b1a0b",
"--foreground": "#e6ffe6",
"--primary-cta": "#80da9b",
"--secondary-cta": "#07170b",
"--accent": "#38714a",
"--background-accent": "#2c6541",
"--primary-cta-text": "#000802",
"--secondary-cta-text": "#e6ffe6"
},
"lightRed": {
"--background": "#080000",
"--card": "#1e0d0d",
"--foreground": "#ffe6e6",
"--primary-cta": "#ff7a7a",
"--secondary-cta": "#1e0909",
"--accent": "#7b4242",
"--background-accent": "#65292c",
"--primary-cta-text": "#080000",
"--secondary-cta-text": "#ffe6e6"
},
"darkRed": {
"--background": "#060000",
"--card": "#1d0d0d",
"--foreground": "#ffe6e6",
"--primary-cta": "#ff3d4a",
"--secondary-cta": "#1f0a0a",
"--accent": "#7b2d2d",
"--background-accent": "#b8111f",
"--primary-cta-text": "#ffffff",
"--secondary-cta-text": "#ffe6e6"
},
"lightPurple": {
"--background": "#050012",
"--card": "#040121",
"--foreground": "#f0e6ff",
"--primary-cta": "#c89bff",
"--secondary-cta": "#1d123b",
"--accent": "#684f7b",
"--background-accent": "#65417c",
"--primary-cta-text": "#050012",
"--secondary-cta-text": "#f0e6ff"
},
"lightOrange": {
"--background": "#080200",
"--card": "#1a0d0b",
"--foreground": "#ffe6d5",
"--primary-cta": "#ffaa70",
"--secondary-cta": "#170b07",
"--accent": "#7b5e4a",
"--background-accent": "#b8541e",
"--primary-cta-text": "#080200",
"--secondary-cta-text": "#ffe6d5"
},
"deepBlue": {
"--background": "#020617",
"--card": "#0f172a",
"--foreground": "#e2e8f0",
"--primary-cta": "#c4d8f9",
"--secondary-cta": "#041633",
"--accent": "#2d30f3",
"--background-accent": "#1d4ed8",
"--primary-cta-text": "#020617",
"--secondary-cta-text": "#e2e8f0"
},
"violet": {
"--background": "#030128",
"--card": "#241f48",
"--foreground": "#ffffff",
"--primary-cta": "#ffffff",
"--secondary-cta": "#131136",
"--accent": "#44358a",
"--background-accent": "#b597fe",
"--primary-cta-text": "#030128",
"--secondary-cta-text": "#d5d4f6"
},
"ruby": {
"--background": "#000000",
"--card": "#481f1f",
"--foreground": "#ffffff",
"--primary-cta": "#ffffff",
"--secondary-cta": "#361311",
"--accent": "#51000b",
"--background-accent": "#ff2231",
"--primary-cta-text": "#280101",
"--secondary-cta-text": "#f6d4d4"
},
"emerald": {
"--background": "#000000",
"--card": "#1f4035",
"--foreground": "#ffffff",
"--primary-cta": "#ffffff",
"--secondary-cta": "#0d2b1f",
"--accent": "#0d5238",
"--background-accent": "#10b981",
"--primary-cta-text": "#051a12",
"--secondary-cta-text": "#d4f6e8"
},
"indigo": {
"--background": "#000000",
"--card": "#1f1f40",
"--foreground": "#ffffff",
"--primary-cta": "#ffffff",
"--secondary-cta": "#0d0d2b",
"--accent": "#3d2880",
"--background-accent": "#663cff",
"--primary-cta-text": "#0a051a",
"--secondary-cta-text": "#d4d4f6"
},
"forest": {
"--background": "#000000",
"--card": "#1a2f1d",
"--foreground": "#ffffff",
"--primary-cta": "#ffffff",
"--secondary-cta": "#0d200f",
"--accent": "#1a3d1f",
"--background-accent": "#355e3b",
"--primary-cta-text": "#0a1a0c",
"--secondary-cta-text": "#d4f6d8"
},
"mint": {
"--background": "#000000",
"--card": "#1a2a1a",
"--foreground": "#ffffff",
"--primary-cta": "#ffffff",
"--secondary-cta": "#0d1a0d",
"--accent": "#2d4a2d",
"--background-accent": "#c1e1c1",
"--primary-cta-text": "#0a150a",
"--secondary-cta-text": "#e1f6e1"
}
}
}

41
cssOptions.json Normal file
View 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
View 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
View 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"
}
}
}
}

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brew &amp; Bean | Premium Artisanal Coffee</title>
<meta property="og:title" content="Brew &amp; Bean | Premium Artisanal Coffee" />
<meta name="twitter:title" content="Brew &amp; Bean | Premium Artisanal Coffee" />
<meta name="description" content="Experience the best of artisanal coffee. Hand-picked beans, fresh roasts, and a cozy local atmosphere. Visit Brew &amp; Bean today." />
<meta property="og:description" content="Experience the best of artisanal coffee. Hand-picked beans, fresh roasts, and a cozy local atmosphere. Visit Brew &amp; Bean today." />
<meta name="twitter:description" content="Experience the best of artisanal coffee. Hand-picked beans, fresh roasts, and a cozy local atmosphere. Visit Brew &amp; Bean today." />
<meta name="keywords" content="coffee shop, specialty coffee, roasted beans, local cafe, artisan brew" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"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": {
"@tailwindcss/vite": "^4.2.2",
"clsx": "^2.1.1",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"motion": "^12.38.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

2228
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

1506
registry.json Normal file

File diff suppressed because it is too large Load Diff

333
src/App.tsx Normal file
View File

@@ -0,0 +1,333 @@
import AboutTextSplit from '@/components/sections/about/AboutTextSplit';
import ContactCenter from '@/components/sections/contact/ContactCenter';
import FaqTwoColumn from '@/components/sections/faq/FaqTwoColumn';
import FeaturesFlipCards from '@/components/sections/features/FeaturesFlipCards';
import FooterBasic from '@/components/sections/footer/FooterBasic';
import HeroBillboardScroll from '@/components/sections/hero/HeroBillboardScroll';
import MetricsFeatureCards from '@/components/sections/metrics/MetricsFeatureCards';
import NavbarCentered from '@/components/ui/NavbarCentered';
import ProductRatingCards from '@/components/sections/product/ProductRatingCards';
import TestimonialMarqueeCards from '@/components/sections/testimonial/TestimonialMarqueeCards';
export default function App() {
return (
<>
<div id="nav" data-section="nav">
<NavbarCentered
logo="Brew & Bean"
navItems={[
{
name: "About",
href: "#about",
},
{
name: "Menu",
href: "#products",
},
{
name: "Testimonials",
href: "#testimonials",
},
{
name: "Contact",
href: "#contact",
},
]}
ctaButton={{
text: "Order Now",
href: "#products",
}}
/>
</div>
<div id="hero" data-section="hero">
<HeroBillboardScroll
tag="Since 2012"
title="Experience the Art of Coffee"
description="Hand-picked beans, small-batch roasted, and served with love in every single cup. Join us for the perfect brew."
primaryButton={{
text: "View Our Menu",
href: "#products",
}}
secondaryButton={{
text: "Find Us",
href: "#contact",
}}
imageSrc="https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/a-high-quality-professional-photograph-o-1776770215122-ab668414.png"
/>
</div>
<div id="about" data-section="about">
<AboutTextSplit
title="Our Craft"
descriptions={[
"Brew & Bean started in a small garage with a passion for high-quality, ethically sourced beans. Today, we continue that tradition by partnering directly with farmers who care about every step of the process.",
"Our roasters use traditional techniques to ensure that each profile—from light and fruity to deep and bold—is developed to perfection. We believe coffee is more than a drink; it's a daily ritual.",
]}
/>
</div>
<div id="features" data-section="features">
<FeaturesFlipCards
tag="Why Us"
title="The Brew & Bean Standard"
description="What makes our coffee stand out in the busy city streets?"
items={[
{
title: "Precision Brewing",
descriptions: [
"Every pour is measured for temperature, timing, and grind size to ensure absolute perfection in your cup.",
],
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/close-up-of-a-high-quality-espresso-mach-1776770238798-399491dd.png",
},
{
title: "Fresh Pastries",
descriptions: [
"From morning buttery croissants to afternoon biscotti, everything is baked fresh every day in our shop.",
],
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/freshly-baked-pastries-on-a-rustic-woode-1776770248914-1bccca3d.png",
},
{
title: "Sustainable Sourcing",
descriptions: [
"We ensure fair wages for our farmers and environmentally conscious practices for every batch of coffee.",
],
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/natural-organic-coffee-plantations-at-su-1776770260488-76b7a848.png",
},
]}
/>
</div>
<div id="products" data-section="products">
<ProductRatingCards
tag="Our Beans"
title="Roasted Daily"
description="Select from our signature blends and single-origin roasts, delivered to your door or enjoyed in-shop."
products={[
{
brand: "Signature",
name: "House Blend",
price: "$16",
rating: 5,
reviewCount: "128",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/small-bag-of-artisan-coffee-beans-clean--1776770272176-901bf6e4.png",
},
{
brand: "Single Origin",
name: "Ethiopia Yirgacheffe",
price: "$22",
rating: 5,
reviewCount: "89",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/small-bag-of-artisan-coffee-beans-clean--1776770283753-569d9b85.png",
},
{
brand: "Single Origin",
name: "Colombia Supremo",
price: "$19",
rating: 4,
reviewCount: "72",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/small-bag-of-artisan-coffee-beans-clean--1776770297723-c9c56483.png",
},
{
brand: "Roast Selection",
name: "Dark Roast Espresso",
price: "$17",
rating: 5,
reviewCount: "210",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/small-bag-of-artisan-coffee-beans-clean--1776770307487-94765e21.png",
},
{
brand: "Limited",
name: "Sumatra Mandheling",
price: "$24",
rating: 4,
reviewCount: "56",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/small-bag-of-artisan-coffee-beans-clean--1776770319162-78ae6775.png",
},
{
brand: "Selection",
name: "Decaf Blend",
price: "$18",
rating: 5,
reviewCount: "44",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/small-bag-of-artisan-coffee-beans-clean--1776770330519-1daeb4b2.png",
},
]}
/>
</div>
<div id="testimonials" data-section="testimonials">
<TestimonialMarqueeCards
tag="Happy Customers"
title="What They Say"
description="Our community is built on great coffee and great conversations."
testimonials={[
{
name: "Alice M.",
role: "Coffee Enthusiast",
quote: "The best espresso I have had in the city. Truly consistent quality.",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/professional-headshot-of-a-happy-custome-1776770341462-c68b1b52.png",
},
{
name: "Bob R.",
role: "Student",
quote: "My favorite place to study. The coffee is rich and the staff is amazing.",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/professional-headshot-of-a-happy-custome-1776770352365-4ae6e337.png",
},
{
name: "Charlie S.",
role: "Regular",
quote: "Amazing vibes and even better beans. A daily must.",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/professional-headshot-of-a-happy-custome-1776770361643-3b8f6ed2.png",
},
{
name: "Dana T.",
role: "Foodie",
quote: "Fantastic selection of roasts and incredible atmosphere.",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/professional-headshot-of-a-happy-custome-1776770372775-b47ad2ff.png",
},
{
name: "Evan L.",
role: "Local",
quote: "Simply the best coffee experience I've had in years.",
imageSrc: "https://storage.googleapis.com/webild/users/user_3Bnee9LjUjtf0A7fYFPtzQldEHH/professional-headshot-of-a-happy-custome-1776770382518-c93ce527.png",
},
]}
/>
</div>
<div id="metrics" data-section="metrics">
<MetricsFeatureCards
tag="Our Impact"
title="Growing Community"
description="We are proud of our milestones and the relationships we've built."
metrics={[
{
value: "10K+",
title: "Cups Served",
features: [
"Quality focused",
"Community driven",
"Sustainability first",
],
},
{
value: "15",
title: "Farmer Partners",
features: [
"Direct trade",
"Fair wages",
"Organic crops",
],
},
{
value: "24",
title: "Unique Roasts",
features: [
"Global selection",
"Small batch",
"Daily fresh",
],
},
]}
/>
</div>
<div id="faq" data-section="faq">
<FaqTwoColumn
tag="Support"
title="Common Questions"
description="Got questions about our coffee, brewing, or our shop?"
items={[
{
question: "Do you offer wholesale?",
answer: "Yes! We work with many cafés and restaurants. Reach out to our team.",
},
{
question: "Can I order online?",
answer: "Absolutely. Our full selection of beans is available in our online shop.",
},
{
question: "Are your beans organic?",
answer: "Most of our single-origin roasts are organically grown by our partners.",
},
{
question: "How should I store beans?",
answer: "Store them in a cool, dark place away from air in an airtight container.",
},
]}
/>
</div>
<div id="contact" data-section="contact">
<ContactCenter
tag="Newsletter"
title="Join the Club"
description="Get early access to our seasonal beans, brewing tips, and shop events."
inputPlaceholder="Enter your email address..."
buttonText="Subscribe"
/>
</div>
<div id="footer" data-section="footer">
<FooterBasic
columns={[
{
title: "Shop",
items: [
{
label: "All Coffee",
href: "#products",
},
{
label: "Brew Gear",
href: "#products",
},
{
label: "Subscriptions",
href: "#products",
},
],
},
{
title: "About",
items: [
{
label: "Our Story",
href: "#about",
},
{
label: "Careers",
href: "#",
},
{
label: "Sustainability",
href: "#",
},
],
},
{
title: "Contact",
items: [
{
label: "Help Center",
href: "#",
},
{
label: "Wholesale",
href: "#",
},
{
label: "Events",
href: "#",
},
],
},
]}
leftText="© 2024 Brew & Bean. All rights reserved."
rightText="Crafted with passion in the heart of the city."
/>
</div>
</>
);
}

View 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 };

View 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 };

View 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 };

View File

@@ -0,0 +1,84 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { resolveIcon } from "@/utils/resolve-icon";
type AboutFeaturesSplitProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: { icon: string | LucideIcon; title: string; description: string }[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutFeaturesSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
imageSrc,
videoSrc,
}: AboutFeaturesSplitProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="flex flex-col gap-8 mx-auto w-content-width">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
<div className="flex flex-col justify-center gap-5 p-5 w-full md:w-4/10 2xl:w-3/10 card rounded">
{items.map((item, index) => {
const ItemIcon = resolveIcon(item.icon);
return (
<div key={item.title}>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<h3 className="text-xl font-medium">{item.title}</h3>
<p className="text-base leading-tight">{item.description}</p>
</div>
{index < items.length - 1 && (
<div className="mt-5 border-b border-accent/40" />
)}
</div>
);
})}
</div>
<div className="p-5 w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</div>
</div>
</section>
);
};
export default AboutFeaturesSplit;

View File

@@ -0,0 +1,70 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutMediaOverlayProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutMediaOverlay = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: AboutMediaOverlayProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="relative flex items-center justify-center py-8 md:py-12 mx-auto w-content-width rounded overflow-hidden">
<div className="absolute inset-0">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
<div className="absolute inset-0 bg-background/40 backdrop-blur-xs pointer-events-none select-none" />
</div>
<div className="relative z-10 flex items-center justify-center px-5 py-10 mx-auto min-h-100 md:min-h-120 md:w-1/2 w-content-width">
<div className="flex flex-col items-center gap-3 md:gap-1 text-center">
<motion.span
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mb-3 px-3 py-1 text-sm card rounded"
>
{tag}
</motion.span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap max-md:justify-center gap-3 mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
</div>
</div>
</section>
);
};
export default AboutMediaOverlay;

View File

@@ -0,0 +1,61 @@
import { motion } from "motion/react";
import { Quote } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutTestimonialProps = {
tag: string;
quote: string;
author: string;
role: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutTestimonial = ({
tag,
quote,
author,
role,
imageSrc,
videoSrc,
}: AboutTestimonialProps) => {
return (
<section aria-label="Testimonial section" className="py-20">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
<div className="relative md:col-span-3 p-8 md:px-12 md:py-20 card rounded">
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
<div className="relative flex flex-col justify-center gap-5 py-8 md:py-5 h-full">
<span className="w-fit px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={quote}
variant="slide-up"
tag="p"
className="text-3xl md:text-4xl font-medium leading-tight"
/>
<div className="flex items-center gap-2">
<span className="text-base">{author}</span>
<span className="text-accent"></span>
<span className="text-base text-foreground/75">{role}</span>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
</div>
</section>
);
};
export default AboutTestimonial;

View File

@@ -0,0 +1,56 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface AboutTextSplitProps {
title: string;
descriptions: string[];
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
}
const AboutTextSplit = ({
title,
descriptions,
primaryButton,
secondaryButton,
}: AboutTextSplitProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="flex flex-col gap-30 mx-auto w-content-width">
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
<div className="w-full md:w-1/2">
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-7xl font-medium"
/>
</div>
<div className="flex flex-col gap-5 w-full md:w-1/2">
{descriptions.map((desc, index) => (
<TextAnimation
key={index}
text={desc}
variant="slide-up"
tag="p"
className="text-base md:text-2xl leading-tight text-foreground/75"
/>
))}
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap max-md:justify-center gap-5">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
</div>
<div className="w-full border-b border-foreground/10" />
</div>
</section>
);
};
export default AboutTextSplit;

View File

@@ -0,0 +1,157 @@
import { motion } from "motion/react";
import { ArrowUpRight, Loader2 } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
category: string;
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col justify-between gap-5 p-5 rounded cursor-pointer"
onClick={handleClick}
>
<div className="flex flex-col gap-2">
<span className="card w-fit rounded px-2 py-0.5 text-xs mb-0.5">{item.category}</span>
<h3 className="text-2xl md:text-3xl font-medium leading-tight line-clamp-2">{item.title}</h3>
<p className="text-sm leading-tight opacity-75 line-clamp-2">{item.excerpt}</p>
<div className="flex items-center gap-3 mt-1.5">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-9 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.authorName}</span>
<span className="text-xs opacity-75">{item.date}</span>
</div>
</div>
</div>
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
</article>
);
};
type BlogMediaCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
};
const BlogMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
}: BlogMediaCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
category: p.category,
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
imageSrc: p.imageSrc,
onClick: p.onBlogClick,
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default BlogMediaCards;

View File

@@ -0,0 +1,160 @@
import { motion } from "motion/react";
import { ArrowUpRight, Loader2 } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
category: string;
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col gap-5 p-5 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
<div className="flex flex-1 flex-col justify-between gap-5">
<div className="flex flex-col gap-2">
<span className="primary-button w-fit rounded px-2 py-0.5 text-xs text-primary-cta-text">
{item.category}
</span>
<h3 className="text-xl font-medium leading-tight mt-1">{item.title}</h3>
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
</div>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-9 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.authorName}</span>
<span className="text-xs opacity-75">{item.date}</span>
</div>
</div>
</div>
</article>
);
};
type BlogSimpleCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
};
const BlogSimpleCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
}: BlogSimpleCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
category: p.category,
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
imageSrc: p.imageSrc,
onClick: p.onBlogClick,
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default BlogSimpleCards;

View File

@@ -0,0 +1,167 @@
import { motion } from "motion/react";
import { ArrowUpRight, Loader2 } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
tags: string[];
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col gap-5 p-5 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
<div className="flex flex-1 flex-col justify-between gap-5">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-5 rounded-full object-cover"
/>
<span className="text-xs opacity-75">
{item.authorName} {item.date}
</span>
</div>
<h3 className="text-xl font-medium leading-tight">{item.title}</h3>
<p className="text-sm leading-tight opacity-75">{item.excerpt}</p>
</div>
<div className="flex flex-wrap gap-2">
{item.tags.map((tag, index) => (
<span
key={index}
className="primary-button rounded px-2 py-0.5 text-xs text-primary-cta-text"
>
{tag}
</span>
))}
</div>
</div>
</article>
);
};
type BlogTagCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
};
const BlogTagCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
}: BlogTagCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
tags: [p.category],
imageSrc: p.imageSrc,
onClick: p.onBlogClick,
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default BlogTagCards;

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import { sendContactEmail } from "@/lib/api/email";
const ContactCenter = ({
tag,
title,
description,
inputPlaceholder,
buttonText,
termsText,
onSubmit,
}: {
tag: string;
title: string;
description: string;
inputPlaceholder: string;
buttonText: string;
termsText?: string;
onSubmit?: (email: string) => void;
}) => {
const [email, setEmail] = useState("");
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await sendContactEmail({ email });
onSubmit?.(email);
setEmail("");
} catch {}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex items-center justify-center py-20 card rounded"
>
<div className="flex flex-col items-center w-full md:w-1/2 gap-3 px-5">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-5xl md:text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-8/10 text-lg leading-tight text-center"
/>
<form
onSubmit={handleSubmit}
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 card rounded"
>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
aria-label="Email address"
/>
<button
type="submit"
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
>
{buttonText}
</button>
</form>
{termsText && (
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
{termsText}
</p>
)}
</div>
</motion.div>
</div>
</section>
);
};
export default ContactCenter;

View File

@@ -0,0 +1,47 @@
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
const ContactCta = ({
tag,
text,
primaryButton,
secondaryButton,
}: {
tag: string;
text: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
}) => {
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex items-center justify-center py-20 px-5 md:px-10 card rounded"
>
<div className="w-full md:w-3/4 flex flex-col items-center gap-3">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={text}
variant="slide-up"
tag="h2"
className="text-4xl md:text-5xl font-medium text-center leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-1">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</motion.div>
</div>
</section>
);
};
export default ContactCta;

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type ContactSplitEmailProps = {
tag: string;
title: string;
description: string;
inputPlaceholder: string;
buttonText: string;
termsText?: string;
onSubmit?: (email: string) => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactSplitEmail = ({
tag,
title,
description,
inputPlaceholder,
buttonText,
termsText,
onSubmit,
imageSrc,
videoSrc,
}: ContactSplitEmailProps) => {
const [email, setEmail] = useState("");
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await sendContactEmail({ email });
onSubmit?.(email);
setEmail("");
} catch {}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-2 gap-5"
>
<div className="flex items-center justify-center py-15 md:py-20 card rounded">
<div className="flex flex-col items-center w-full gap-3 px-5">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-5xl md:text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-8/10 text-lg leading-tight text-center"
/>
<form
onSubmit={handleSubmit}
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 card rounded"
>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
aria-label="Email address"
/>
<button
type="submit"
className="primary-button h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer"
>
{buttonText}
</button>
</form>
{termsText && (
<p className="text-xs opacity-75 text-center md:max-w-8/10 2xl:max-w-6/10">
{termsText}
</p>
)}
</div>
</div>
<div className="h-100 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</motion.div>
</div>
</section>
);
};
export default ContactSplitEmail;

View File

@@ -0,0 +1,144 @@
import { useState } from "react";
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type InputField = {
name: string;
type: string;
placeholder: string;
required?: boolean;
};
type TextareaField = {
name: string;
placeholder: string;
rows?: number;
required?: boolean;
};
type ContactSplitFormProps = {
tag: string;
title: string;
description: string;
inputs: InputField[];
textarea?: TextareaField;
buttonText: string;
onSubmit?: (data: Record<string, string>) => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactSplitForm = ({
tag,
title,
description,
inputs,
textarea,
buttonText,
onSubmit,
imageSrc,
videoSrc,
}: ContactSplitFormProps) => {
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
inputs.forEach((input) => {
initial[input.name] = "";
});
if (textarea) {
initial[textarea.name] = "";
}
return initial;
});
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await sendContactEmail({ formData });
onSubmit?.(formData);
const reset: Record<string, string> = {};
inputs.forEach((input) => {
reset[input.name] = "";
});
if (textarea) {
reset[textarea.name] = "";
}
setFormData(reset);
} catch {}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-2 gap-5"
>
<div className="p-5 md:p-10 card rounded">
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col items-center gap-1 text-center">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-4xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-sm md:text-base leading-tight text-balance"
/>
</div>
<div className="flex flex-col gap-3">
{inputs.map((input) => (
<input
key={input.name}
type={input.type}
placeholder={input.placeholder}
value={formData[input.name] || ""}
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
required={input.required}
aria-label={input.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
/>
))}
{textarea && (
<textarea
placeholder={textarea.placeholder}
value={formData[textarea.name] || ""}
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
required={textarea.required}
rows={textarea.rows || 5}
aria-label={textarea.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
/>
)}
<button
type="submit"
className="w-full h-9 px-5 text-sm rounded text-primary-cta-text cursor-pointer primary-button"
>
{buttonText}
</button>
</div>
</form>
</div>
<div className="h-100 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</motion.div>
</div>
</section>
);
};
export default ContactSplitForm;

View File

@@ -0,0 +1,109 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqSimple = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col gap-3"
>
{items.map((item, index) => (
<div
key={index}
className="card rounded p-3 md:p-5 cursor-pointer select-none"
onClick={() => handleToggle(index)}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg md:text-xl font-medium leading-tight">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 rounded primary-button">
<Plus
className={cls(
"size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 md:pt-0 text-sm md:text-base leading-relaxed opacity-75">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</motion.div>
</div>
</section>
);
};
export default FaqSimple;

View File

@@ -0,0 +1,130 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
type FaqSplitMediaProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const FaqSplitMedia = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
imageSrc,
videoSrc,
}: FaqSplitMediaProps) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="card relative md:col-span-2 h-80 md:h-auto rounded overflow-hidden"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 size-full object-cover"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
className="md:col-span-3 flex flex-col gap-3"
>
{items.map((item, index) => (
<div
key={index}
className="card rounded p-3 md:p-5 cursor-pointer select-none"
onClick={() => handleToggle(index)}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-2 text-sm leading-relaxed opacity-75">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
};
export default FaqSplitMedia;

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqTwoColumn = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
const halfLength = Math.ceil(items.length / 2);
const firstColumn = items.slice(0, halfLength);
const secondColumn = items.slice(halfLength);
const renderAccordionItem = (item: FaqItem, index: number) => (
<div
key={index}
className="secondary-button rounded p-3 md:p-5 cursor-pointer select-none"
onClick={() => handleToggle(index)}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-base md:text-lg font-medium leading-tight">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-7 md:size-8 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-2 text-sm leading-relaxed opacity-75">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="card rounded px-3 py-1 text-sm">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="card rounded p-3 md:p-5"
>
<div className="flex flex-col md:flex-row gap-3 md:gap-5">
<div className="flex flex-1 flex-col gap-3 md:gap-5">
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
</div>
{secondColumn.length > 0 && (
<div className="flex flex-1 flex-col gap-3 md:gap-5">
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
</div>
)}
</div>
</motion.div>
</div>
</section>
);
};
export default FaqTwoColumn;

View File

@@ -0,0 +1,93 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesAlternatingSplitProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesAlternatingSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesAlternatingSplitProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{items.map((item, index) => (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls("flex flex-col gap-5 md:gap-12 p-5 md:p-12 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
>
<div className="flex flex-col justify-center w-full md:w-1/2 gap-3">
<span className="flex items-center justify-center size-8 mb-1 text-sm rounded primary-button text-primary-cta-text">
{index + 1}
</span>
<h3 className="text-4xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
<p className="text-base leading-tight text-balance">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
</div>
)}
</div>
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default FeaturesAlternatingSplit;

View File

@@ -0,0 +1,90 @@
import { motion } from "motion/react";
import { ArrowRight } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type FeatureItem = {
title: string;
tags: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesArrowCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesArrowCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesArrowCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-5 h-full cursor-pointer group">
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
</div>
<div className="flex flex-col justify-between gap-5 p-5 flex-1 card rounded">
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-between gap-5">
<div className="flex flex-wrap items-center gap-2">
{item.tags.map((itemTag) => (
<span key={itemTag} className="px-3 py-1 text-sm card rounded">{itemTag}</span>
))}
</div>
<ArrowRight className="shrink-0 h-[1em] w-auto transition-transform duration-300 group-hover:-rotate-45" strokeWidth={1.5} />
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesArrowCards;

View File

@@ -0,0 +1,104 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
import TiltedStackCards from "@/components/ui/TiltedStackCards";
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
import OrbitingIcons from "@/components/ui/OrbitingIcons";
import IconTextMarquee from "@/components/ui/IconTextMarquee";
import ChatMarquee from "@/components/ui/ChatMarquee";
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
import MediaStack from "@/components/ui/MediaStack";
import type { LucideIcon } from "lucide-react";
type IconInput = string | LucideIcon;
type FeatureCard = { title: string; description: string } & (
| { bentoComponent: "info-card-marquee"; items: { icon: IconInput; label: string; value: string }[] }
| { bentoComponent: "tilted-stack-cards"; items: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
| { bentoComponent: "animated-bar-chart" }
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; items: IconInput[] }
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; texts: string[] }
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; items: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
| { bentoComponent: "media-stack"; items: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
);
const getBentoComponent = (feature: FeatureCard) => {
switch (feature.bentoComponent) {
case "info-card-marquee": return <InfoCardMarquee items={feature.items} />;
case "tilted-stack-cards": return <TiltedStackCards items={feature.items} />;
case "animated-bar-chart": return <AnimatedBarChart />;
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.items} />;
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.texts} />;
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.items} completedLabel={feature.completedLabel} />;
case "media-stack": return <MediaStack items={feature.items} />;
}
};
const FeaturesBento = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureCard[];
}) => (
<section aria-label="Features bento section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{features.map((feature) => (
<div key={feature.title} className="flex flex-col gap-5 p-5 card rounded h-full">
<div className="relative h-72 overflow-hidden">{getBentoComponent(feature)}</div>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
<p className="text-sm leading-tight">{feature.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default FeaturesBento;

View File

@@ -0,0 +1,83 @@
import { motion } from "motion/react";
import { Check, X } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface FeaturesComparisonProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
negativeItems: string[];
positiveItems: string[];
}
const FeaturesComparison = ({
tag,
title,
description,
primaryButton,
secondaryButton,
negativeItems,
positiveItems,
}: FeaturesComparisonProps) => {
return (
<section aria-label="Features comparison section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="grid grid-cols-1 md:grid-cols-2 w-content-width md:w-6/10 mx-auto gap-5"
>
<div className="flex flex-col gap-5 p-5 card rounded opacity-50">
{negativeItems.map((item) => (
<div key={item} className="flex items-center gap-2 text-base">
<X className="shrink-0 h-[1em] w-auto" />
<span className="text-base truncate">{item}</span>
</div>
))}
</div>
<div className="flex flex-col gap-5 p-5 card rounded">
{positiveItems.map((item) => (
<div key={item} className="flex items-center gap-2 text-base">
<Check className="shrink-0 h-[1em] w-auto" />
<span className="text-base truncate">{item}</span>
</div>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default FeaturesComparison;

View File

@@ -0,0 +1,94 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type FeatureItem = {
title: string;
description: string;
tags: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesDetailedCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesDetailedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesDetailedCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col w-content-width mx-auto gap-5">
{items.map((item) => (
<motion.article
key={item.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:grid md:grid-cols-10 2xl:w-8/10 mx-auto gap-5 md:gap-10 p-5 md:p-10 cursor-pointer card rounded group"
>
<div className="flex flex-col md:col-span-6 gap-3 md:gap-12">
<h3 className="text-3xl md:text-5xl font-medium leading-tight text-balance">{item.title}</h3>
<div className="flex flex-col mt-auto gap-5">
<div className="flex flex-wrap gap-2">
{item.tags.map((itemTag) => (
<span key={itemTag} className="px-3 py-1 text-sm card rounded">{itemTag}</span>
))}
</div>
<p className="text-base md:text-2xl leading-tight text-balance">
{item.description}
</p>
</div>
</div>
<div className="aspect-square md:col-span-4 rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
</div>
</motion.article>
))}
</div>
</div>
</section>
);
};
export default FeaturesDetailedCards;

View File

@@ -0,0 +1,97 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type StepItem = {
tag: string;
title: string;
subtitle: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesDetailedStepsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
steps: StepItem[];
}
const FeaturesDetailedSteps = ({
tag,
title,
description,
primaryButton,
secondaryButton,
steps,
}: FeaturesDetailedStepsProps) => {
return (
<section aria-label="Features detailed steps section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col w-content-width mx-auto gap-5">
{steps.map((step, index) => {
const stepNumber = String(index + 1).padStart(2, "0");
return (
<motion.div
key={step.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-5 md:gap-12 p-5 md:p-12 card rounded overflow-hidden"
>
<div className="flex flex-col justify-between w-full md:w-1/2">
<div className="flex flex-col gap-5">
<span className="w-fit px-3 py-1 text-sm card rounded">{step.tag}</span>
<h3 className="text-5xl md:text-8xl font-medium leading-none">{step.title}</h3>
</div>
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
<div className="flex flex-col gap-2 md:gap-5">
<h4 className="text-2xl md:text-3xl font-medium text-balance">{step.subtitle}</h4>
<p className="text-base md:text-lg leading-tight text-balance">{step.description}</p>
</div>
</div>
<div className="flex flex-col w-full md:w-35/100 gap-10">
<span className="hidden md:block self-end text-8xl font-medium text-accent">{stepNumber}</span>
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
</div>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
};
export default FeaturesDetailedSteps;

View File

@@ -0,0 +1,100 @@
import { motion } from "motion/react";
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
mediaItems: [
({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }),
({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })
];
};
interface FeaturesDualMediaProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesDualMedia = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesDualMediaProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={2}>
{items.map((item) => {
const IconComponent = resolveIcon(item.icon);
return (
<div key={item.title} className="flex flex-col gap-5 p-5 h-full card rounded">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-center mb-1 size-15 primary-button rounded">
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<p className="text-base leading-tight">{item.description}</p>
</div>
<div className="grid grid-cols-2 flex-1 mt-auto gap-5">
{item.mediaItems.map((mediaItem, mediaIndex) => (
<div key={mediaIndex} className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={mediaItem.imageSrc} videoSrc={mediaItem.videoSrc} />
</div>
))}
</div>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesDualMedia;

View File

@@ -0,0 +1,118 @@
import { useState } from "react";
import { motion } from "motion/react";
import { Plus } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
descriptions: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesFlipCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeatureFlipCard = ({ item }: { item: FeatureItem }) => {
const [isFlipped, setIsFlipped] = useState(false);
return (
<div
className="relative w-full cursor-pointer perspective-[3000px]"
onClick={() => setIsFlipped(!isFlipped)}
>
<div
data-flipped={isFlipped}
className="relative w-full h-full transition-transform duration-500 transform-3d data-[flipped=true]:transform-[rotateY(180deg)]"
>
<div className="flex flex-col gap-5 p-5 card rounded backface-hidden">
<div className="flex items-start justify-between gap-5">
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
<Plus className="h-2/5 w-2/5 text-primary-cta-text" />
</div>
</div>
<div className="relative overflow-hidden aspect-4/5 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-between gap-5 p-5 card rounded backface-hidden transform-[rotateY(180deg)]">
<div className="flex items-start justify-between gap-5">
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-center shrink-0 size-8 primary-button rounded">
<Plus className="h-2/5 w-2/5 rotate-45 text-primary-cta-text" />
</div>
</div>
<div className="flex flex-col gap-3">
{item.descriptions.map((desc, index) => (
<p key={index} className="text-lg leading-tight text-foreground/75">{desc}</p>
))}
</div>
</div>
</div>
</div>
);
};
const FeaturesFlipCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesFlipCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item) => (
<FeatureFlipCard key={item.title} item={item} />
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesFlipCards;

View File

@@ -0,0 +1,90 @@
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
import HoverPattern from "@/components/ui/HoverPattern";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import type { LucideIcon } from "lucide-react";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
};
interface FeaturesIconCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureItem[];
}
const FeaturesIconCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
}: FeaturesIconCardsProps) => {
return (
<section aria-label="Features icon cards section" className="py-20">
<div className="flex flex-col w-content-width mx-auto gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{features.map((feature) => {
const FeatureIcon = resolveIcon(feature.icon);
return (
<div key={feature.title} className="flex flex-col gap-5 p-5 card rounded">
<HoverPattern className="flex items-center justify-center aspect-square rounded">
<div className="relative z-10 flex items-center justify-center size-12 primary-button rounded shadow">
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
</div>
</HoverPattern>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-medium leading-tight">{feature.title}</h3>
<p className="text-sm leading-tight">{feature.description}</p>
</div>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesIconCards;

View File

@@ -0,0 +1,100 @@
import { Fragment } from "react";
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
type FeatureItem = {
label: string;
title: string;
bullets: string[];
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
};
interface FeaturesLabeledListProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesLabeledList = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesLabeledListProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{items.map((item) => (
<motion.div
key={item.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row gap-5 md:gap-12 p-5 md:p-12 card rounded"
>
<div className="w-full md:w-1/2 flex md:justify-start">
<h3 className="text-7xl font-medium leading-tight truncate">{item.label}</h3>
</div>
<div className="w-full h-px bg-foreground/20 md:hidden" />
<div className="flex flex-col w-full md:w-1/2 gap-5">
<h4 className="text-2xl md:text-3xl font-medium leading-tight">{item.title}</h4>
<div className="flex flex-wrap items-center gap-1">
{item.bullets.map((text, index) => (
<Fragment key={index}>
<span className="text-base">{text}</span>
{index < item.bullets.length - 1 && <span className="text-base text-accent"></span>}
</Fragment>
))}
</div>
<div className="flex flex-wrap gap-3 mt-2">
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />
<Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default FeaturesLabeledList;

View File

@@ -0,0 +1,82 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesMediaCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-5 p-5 h-full card rounded">
<div className="aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-medium leading-tight">{item.title}</h3>
<p className="text-sm leading-tight">{item.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesMediaCards;

View File

@@ -0,0 +1,99 @@
import type { LucideIcon } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import LoopCarousel from "@/components/ui/LoopCarousel";
import Button from "@/components/ui/Button";
import { useButtonClick } from "@/hooks/useButtonClick";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
title: string;
description: string;
buttonIcon: string | LucideIcon;
buttonHref?: string;
buttonOnClick?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaCarouselProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeatureMediaCarouselCard = ({ item }: { item: FeatureItem }) => {
const handleClick = useButtonClick(item.buttonHref, item.buttonOnClick);
const Icon = resolveIcon(item.buttonIcon);
return (
<div className="relative overflow-hidden aspect-square md:aspect-3/2 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute bottom-0 left-0 w-full h-1/3 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-linear-to-t from-foreground/60 to-transparent" />
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-5 p-5">
<div className="flex flex-col min-w-0">
<h3 className="text-2xl md:text-3xl font-medium leading-tight text-background">{item.title}</h3>
<p className="text-sm md:text-base leading-tight text-background/75">{item.description}</p>
</div>
<button
onClick={handleClick}
type="button"
aria-label={item.buttonHref ? `Navigate to ${item.buttonHref}` : "Action button"}
className="flex items-center justify-center shrink-0 size-8 cursor-pointer primary-button rounded"
>
<Icon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</button>
</div>
</div>
);
};
const FeaturesMediaCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesMediaCarouselProps) => {
return (
<section aria-label="Features section" className="w-full py-20">
<div className="flex flex-col w-full gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<LoopCarousel>
{items.map((item) => (
<FeatureMediaCarouselCard key={item.title} item={item} />
))}
</LoopCarousel>
</div>
</section>
);
};
export default FeaturesMediaCarousel;

View File

@@ -0,0 +1,104 @@
import { motion } from "motion/react";
import { BadgeCheck } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
avatarSrc: string;
buttonText: string;
buttonHref?: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const FeaturesProfileCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}) => (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="group relative overflow-hidden aspect-5/6 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute top-5 right-5 z-20">
<Button text={item.buttonText} href={item.buttonHref} variant="primary" />
</div>
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
<div className="relative flex flex-col gap-1 p-3">
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
<div className="flex items-center gap-2">
<div className="size-8 shrink-0 overflow-hidden rounded secondary-button">
<img src={item.avatarSrc} alt="" className="h-full w-full object-cover" />
</div>
<h3 className="text-xl font-semibold leading-tight truncate text-background transition-colors duration-400 group-hover:text-foreground">
{item.title}
</h3>
<BadgeCheck className="size-5 shrink-0 text-background transition-colors duration-400 group-hover:text-foreground" strokeWidth={2} />
</div>
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default FeaturesProfileCards;

View File

@@ -0,0 +1,105 @@
import { motion } from "motion/react";
import { Info } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesRevealCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesRevealCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesRevealCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item, index) => (
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute top-5 left-5 z-20 perspective-[1000px]">
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden">
<span className="text-sm font-medium text-foreground">{index + 1}</span>
</div>
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 z-10 p-1">
<div className="relative flex flex-col gap-1 p-3">
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
<h3 className="text-2xl font-semibold leading-tight text-background transition-colors duration-400 group-hover:text-foreground">
{item.title}
</h3>
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-sm leading-tight text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesRevealCards;

View File

@@ -0,0 +1,90 @@
import { motion } from "motion/react";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
label: string;
value: string;
};
interface FeaturesStatisticsCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesStatisticsCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesStatisticsCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col h-full card rounded">
<div className="flex flex-col flex-1 gap-10 p-5">
<div className="flex flex-col gap-1">
<h3 className="text-2xl md:text-3xl font-medium leading-tight truncate">{item.title}</h3>
<p className="text-base md:text-lg leading-tight text-foreground/75">{item.description}</p>
</div>
<div className="flex items-center justify-between gap-2 mt-auto">
<div className="flex items-center min-w-0 flex-1 gap-2">
<span className="shrink-0 size-[1em] rounded-sm bg-accent" />
<span className="text-base truncate">{item.label}</span>
</div>
<span className="text-xl md:text-2xl font-medium">{item.value}</span>
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesStatisticsCards;

View File

@@ -0,0 +1,92 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type FeatureItem = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesTaggedCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesTaggedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesTaggedCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-5 h-full group">
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
<span className="absolute top-5 right-5 px-3 py-1 text-sm card rounded">{item.tag}</span>
</div>
<div className="flex flex-col gap-5 p-5 flex-1 card rounded">
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
<p className="text-base leading-tight">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
</div>
)}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
};
export default FeaturesTaggedCards;

View File

@@ -0,0 +1,123 @@
import { useState, useEffect, useRef } from "react";
import { cls } from "@/lib/utils";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Transition from "@/components/ui/Transition";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesTimelineCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesTimelineCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesTimelineCardsProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
setProgress(0);
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
}, 50);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [activeIndex]);
useEffect(() => {
if (progress === 100) {
setActiveIndex((i) => (i + 1) % items.length);
}
}, [progress, items.length]);
const handleCardClick = (index: number) => {
if (index !== activeIndex) setActiveIndex(index);
};
return (
<section aria-label="Features timeline section" className="py-20">
<div className="flex flex-col w-content-width mx-auto gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<Transition className="flex flex-col gap-5">
<div className="relative aspect-square md:aspect-10/4 overflow-hidden card rounded">
<Transition key={activeIndex} transitionType="full" className="absolute inset-6 overflow-hidden rounded">
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
</Transition>
</div>
<div className={cls(
"grid grid-cols-1 gap-5",
items.length === 2 && "md:grid-cols-2",
items.length === 3 && "md:grid-cols-3",
items.length >= 4 && "md:grid-cols-4"
)}>
{items.map((item, index) => (
<div
key={item.title}
data-active={index === activeIndex}
onClick={() => handleCardClick(index)}
className="flex flex-col justify-between gap-5 p-5 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center size-8 primary-button rounded">
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
</div>
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
<p className="text-base leading-tight text-balance">{item.description}</p>
</div>
<div className="relative w-full h-px overflow-hidden">
<div className="absolute inset-0 bg-foreground/20" />
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
</div>
</div>
))}
</div>
</Transition>
</div>
</section>
);
};
export default FeaturesTimelineCards;

View File

@@ -0,0 +1,61 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBasic = ({
columns,
leftText,
rightText,
}: {
columns: FooterColumn[];
leftText: string;
rightText: string;
}) => {
return (
<footer aria-label="Site footer" className="w-full pt-20 pb-10">
<div className="w-content-width mx-auto">
<div className="w-full flex flex-wrap justify-between gap-y-10 mb-10">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="w-full flex items-center justify-between pt-5">
<span className="text-sm opacity-50">{leftText}</span>
<span className="text-sm opacity-50">{rightText}</span>
</div>
</div>
</footer>
);
};
export default FooterBasic;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,62 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboard = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBillboardProps) => {
return (
<section aria-label="Hero section" className="pt-25 pb-20 md:py-30">
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full p-3 md:p-5 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboard;

View File

@@ -0,0 +1,54 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
type HeroBillboardBrandProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardBrand = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBillboardBrandProps) => {
return (
<section aria-label="Hero section" className="pt-25 pb-20 md:py-30">
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
<div className="flex flex-col items-end gap-5">
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl leading-tight text-balance text-right"
/>
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full p-3 md:p-5 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboardBrand;

View File

@@ -0,0 +1,69 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardCarouselProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const HeroBillboardCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroBillboardCarouselProps) => {
const duplicated = [...items, ...items, ...items, ...items];
return (
<section
aria-label="Hero section"
className="flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
>
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
{duplicated.map((item, i) => (
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-1.5 card rounded-lg overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="w-full h-full rounded-lg object-cover"
/>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroBillboardCarousel;

View File

@@ -0,0 +1,76 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardScrollProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardScroll = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBillboardScrollProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: containerRef });
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
return (
<section aria-label="Hero section">
<div
ref={containerRef}
className="pt-25 pb-20 md:py-30 perspective-distant"
>
<div className="w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<div className="w-content-width mx-auto mt-8 p-3 card rounded overflow-hidden rotate-x-20 md:hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
</div>
<motion.div
style={{ rotateX: rotate, scale }}
className="w-content-width mx-auto mt-5 2xl:mt-2 p-5 card rounded overflow-hidden hidden md:block"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboardScroll;

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroBillboardTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroBillboardTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
}: HeroBillboardTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section aria-label="Hero section" className="pt-25 pb-20 md:py-30">
<div className="flex flex-col gap-10 md:gap-13 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="relative w-full p-3 md:p-5 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute bottom-6 left-6 right-6 md:left-10 md:bottom-10 md:right-auto md:max-w-sm p-5 card rounded flex flex-col gap-5"
>
<div className="flex gap-1">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-tight text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{testimonial.name}</span>
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</section>
);
};
export default HeroBillboardTestimonial;

View File

@@ -0,0 +1,55 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import TiltedCarousel from "@/components/ui/TiltedCarousel";
type HeroBillboardTiltedCarouselProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const HeroBillboardTiltedCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroBillboardTiltedCarouselProps) => {
return (
<section
aria-label="Hero section"
className="flex flex-col items-center justify-center gap-8 w-full min-h-svh py-25"
>
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<TiltedCarousel items={items} />
</section>
);
};
export default HeroBillboardTiltedCarousel;

View File

@@ -0,0 +1,62 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
type HeroBrandProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBrand = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroBrandProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-5">
<div className="flex flex-col">
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-medium leading-tight"
/>
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
<div className="flex flex-wrap gap-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
<AutoFillText className="font-semibold">{brand}</AutoFillText>
</div>
</div>
</section>
);
};
export default HeroBrand;

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import { cls } from "@/lib/utils";
type HeroBrandCarouselProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const INTERVAL = 4000;
const HeroBrandCarousel = ({
brand,
description,
primaryButton,
secondaryButton,
items,
}: HeroBrandCarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % items.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, items.length]);
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
>
{items.map((item, index) => (
<div
key={index}
className={cls(
"absolute inset-0 transition-opacity duration-500",
currentIndex === index ? "opacity-100 z-1" : "opacity-0 pointer-events-none"
)}
aria-hidden={currentIndex !== index}
>
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
</div>
))}
<div
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-5">
<div className="flex flex-col">
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-medium leading-tight"
/>
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
<div className="flex flex-wrap gap-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
<AutoFillText className="font-semibold">{brand}</AutoFillText>
<div className="flex gap-3 pb-5">
{items.map((_, index) => (
<button
key={index}
className="relative h-1 w-full rounded overflow-hidden bg-foreground/20 cursor-pointer"
onClick={() => setCurrentIndex(index)}
aria-label="Slide"
aria-current={currentIndex === index}
>
<div
className={cls(
"absolute inset-0 bg-foreground rounded origin-left",
currentIndex === index ? "animate-progress" : (index < currentIndex ? "scale-x-100" : "scale-x-0")
)}
style={{ animationDuration: `${INTERVAL}ms` }}
/>
</button>
))}
</div>
</div>
</div>
</section>
);
};
export default HeroBrandCarousel;

View File

@@ -0,0 +1,67 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
type HeroCenteredLogosProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
logos: string[];
};
const HeroCenteredLogos = ({
tag,
title,
description,
primaryButton,
secondaryButton,
logos,
}: HeroCenteredLogosProps) => {
return (
<section aria-label="Hero section" className="h-svh flex flex-col">
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 pt-8 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="md:max-w-7/10 text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-content-width mx-auto pb-8 overflow-hidden mask-fade-x"
>
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
{[...logos, ...logos, ...logos, ...logos].map((logo, index) => (
<div key={index} className="shrink-0 mx-3 px-4 py-2 rounded card">
<span className="text-xl font-semibold whitespace-nowrap opacity-75">{logo}</span>
</div>
))}
</div>
</motion.div>
</section>
);
};
export default HeroCenteredLogos;

View File

@@ -0,0 +1,66 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroOverlayProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroOverlay = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroOverlayProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 bottom-0 -translate-x-1/2 translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-10 md:pb-25">
<div className="flex flex-col gap-3 w-full md:w-4/10 2xl:w-35/100">
<span className="w-fit px-3 py-1 mb-1.5 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap gap-3 mt-1.5">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
</section>
);
};
export default HeroOverlay;

View File

@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroOverlayTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroOverlayTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
}: HeroOverlayTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-start"
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pt-35">
<div className="flex flex-col gap-3 w-full md:w-4/10 2xl:w-35/100">
<span className="w-fit px-3 py-1 mb-1.5 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap gap-3 mt-1.5">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animateImmediately />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animateImmediately delay={0.1} />
</div>
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute z-10 bottom-3 left-3 right-3 p-5 card rounded flex flex-col gap-5 md:left-auto md:bottom-8 md:right-8 md:max-w-25/100 2xl:max-w-2/10"
>
<div className="flex gap-1">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-tight text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{testimonial.name}</span>
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</section>
);
};
export default HeroOverlayTestimonial;

View File

@@ -0,0 +1,64 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
}: HeroSplitProps) => {
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-3 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
</div>
</section>
);
};
export default HeroSplit;

View File

@@ -0,0 +1,129 @@
import { useEffect, useRef } from "react";
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type KpiItem = {
value: string;
label: string;
};
type HeroSplitKpiProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
kpis: [KpiItem, KpiItem, KpiItem];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const KPI_POSITIONS = ["top-[5%] left-0", "top-[40%] right-0", "bottom-[5%] left-[5%]"];
const HeroSplitKpi = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
kpis,
}: HeroSplitKpiProps) => {
const kpiRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
useEffect(() => {
if (window.innerWidth <= 768) return;
let mouseX = 0;
let mouseY = 0;
const offsets = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
const multipliers = [-0.25, -0.5, 0.25];
let animationId: number;
const onMouseMove = (e: MouseEvent) => {
mouseX = (e.clientX / window.innerWidth) * 100 - 50;
mouseY = (e.clientY / window.innerHeight) * 100 - 50;
};
const animate = () => {
offsets.forEach((offset, i) => {
offset.x += ((mouseX * multipliers[i]) - offset.x) * 0.025;
offset.y += ((mouseY * multipliers[i]) - offset.y) * 0.025;
const el = kpiRefs.current[i];
if (el) el.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
});
animationId = requestAnimationFrame(animate);
};
animate();
window.addEventListener("mousemove", onMouseMove);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(animationId);
};
}, []);
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full h-full p-3 card rounded overflow-hidden scale-80"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
{kpis.map((kpi, index) => (
<motion.div
key={index}
ref={(el) => { kpiRefs.current[index] = el; }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: "easeOut", delay: 0.4 + index * 0.1 }}
className={cls(
"absolute flex flex-col items-center p-3 md:p-5 card backdrop-blur-sm rounded",
KPI_POSITIONS[index]
)}
>
<p className="text-2xl md:text-4xl font-medium">{kpi.value}</p>
<p className="text-sm md:text-base text-foreground/70">{kpi.label}</p>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default HeroSplitKpi;

View File

@@ -0,0 +1,71 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitMediaGridProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: [
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never },
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }
];
};
const HeroSplitMediaGrid = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroSplitMediaGridProps) => {
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="w-full md:w-1/2 grid grid-cols-2 gap-3"
>
{items.map((item, index) => (
<div key={index} className="h-80 md:h-[55vh] p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</motion.div>
</div>
</section>
);
};
export default HeroSplitMediaGrid;

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroSplitTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroSplitTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
}: HeroSplitTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-3 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute bottom-6 left-6 right-6 md:left-auto md:max-w-5/10 p-5 card rounded flex flex-col gap-5"
>
<div className="flex gap-1">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-tight text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{testimonial.name}</span>
<span className="text-sm text-foreground/60">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</section>
);
};
export default HeroSplitTestimonial;

View File

@@ -0,0 +1,81 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitVerticalMarqueeProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
};
const HeroSplitVerticalMarquee = ({
tag,
title,
description,
primaryButton,
secondaryButton,
leftItems,
rightItems,
}: HeroSplitVerticalMarqueeProps) => {
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<span className="px-3 py-1 mb-2 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="fade"
tag="h1"
className="text-7xl 2xl:text-8xl font-medium text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant="fade"
tag="p"
className="max-w-8/10 text-lg md:text-xl leading-tight text-center md:text-left"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
</div>
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-3 overflow-hidden">
<div className="flex-1 overflow-hidden mask-fade-y">
<div className="flex flex-col gap-3 animate-marquee-vertical">
{duplicatedLeft.map((item, index) => (
<div key={index} className="shrink-0 aspect-square p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
<div className="flex-1 overflow-hidden mask-fade-y">
<div className="flex flex-col gap-3 animate-marquee-vertical-reverse">
{duplicatedRight.map((item, index) => (
<div key={index} className="shrink-0 aspect-square p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
};
export default HeroSplitVerticalMarquee;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
interface HeroTiltedCardsProps {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
}
const HeroTiltedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroTiltedCardsProps) => {
const marqueeItems = [...items, ...items];
const galleryStyles = [
"-rotate-6 z-10 -translate-y-5",
"rotate-6 z-20 translate-y-5 -ml-15",
"-rotate-6 z-30 -translate-y-5 -ml-15",
"rotate-6 z-40 translate-y-5 -ml-15",
"-rotate-6 z-50 -translate-y-5 -ml-15",
];
return (
<section aria-label="Hero section" className="flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<div className="flex flex-col items-center gap-10 md:gap-15 w-full md:w-content-width mx-auto">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto text-center">
<span className="px-3 py-1 mb-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h1"
className="text-6xl font-medium text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="text-base md:text-lg leading-tight text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="block md:hidden w-full overflow-hidden mask-padding-x"
>
<div className="flex w-max animate-marquee-horizontal">
{marqueeItems.map((item, index) => (
<div key={index} className="shrink-0 w-[50vw] mr-5 aspect-4/5 p-2 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
className="hidden md:flex justify-center items-center w-full"
>
<div className="flex items-center justify-center">
{items.map((item, index) => (
<div
key={index}
className={cls(
"relative w-[23%] aspect-4/5 p-2 card rounded overflow-hidden shadow-lg transition-transform duration-500 ease-out hover:scale-110",
galleryStyles[index]
)}
>
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</motion.div>
</div>
</section>
);
};
export default HeroTiltedCards;

View 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;

View File

@@ -0,0 +1,87 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type Metric = {
value: string;
title: string;
features: string[];
};
const MetricsFeatureCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 h-full card rounded">
<div className="flex flex-col gap-0">
<span className="text-8xl md:text-7xl font-medium leading-none truncate">{metric.value}</span>
<span className="text-xl truncate">{metric.title}</span>
</div>
<div className="flex flex-col gap-3 pt-5 border-t border-accent">
{metric.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-sm leading-tight">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsFeatureCards;

View File

@@ -0,0 +1,93 @@
import { motion } from "motion/react";
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import { resolveIcon } from "@/utils/resolve-icon";
type Metric = {
value: string;
title: string;
description: string;
icon: string | LucideIcon;
};
const MetricsGradientCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => {
const IconComponent = resolveIcon(metric.icon);
return (
<div key={metric.value} className="relative flex flex-col items-center justify-center gap-0 p-5 min-h-70 h-full card rounded">
<span
className="text-9xl font-medium leading-none text-center truncate"
style={{
backgroundImage: "linear-gradient(to bottom, var(--color-foreground) 0%, var(--color-foreground) 20%, transparent 72%)",
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{metric.value}
</span>
<span className="mt-[-0.75em] text-4xl font-medium text-center truncate">{metric.title}</span>
<p className="max-w-9/10 md:max-w-7/10 mt-2 text-base leading-tight text-center line-clamp-2">{metric.description}</p>
<div className="absolute bottom-5 left-5 flex items-center justify-center size-10 primary-button rounded">
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" />
</div>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsGradientCards;

View File

@@ -0,0 +1,83 @@
import { motion } from "motion/react";
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import { resolveIcon } from "@/utils/resolve-icon";
type Metric = {
icon: string | LucideIcon;
title: string;
value: string;
};
const MetricsIconCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => {
const IconComponent = resolveIcon(metric.icon);
return (
<div key={metric.value} className="flex flex-col items-center justify-center gap-3 p-5 min-h-70 h-full card rounded">
<div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center size-8 primary-button rounded">
<IconComponent className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<span className="text-xl truncate">{metric.title}</span>
</div>
<span className="text-7xl font-medium leading-none truncate">{metric.value}</span>
</div>
);
})}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsIconCards;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Metric = {
value: string;
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const MetricsMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 w-content-width mx-auto">
{metrics.map((metric, index) => {
const isEven = index % 2 === 1;
const isLast = index === metrics.length - 1;
const isOddTotal = metrics.length % 2 !== 0;
const shouldSpanFull = isLast && isOddTotal;
return (
<motion.div
key={metric.value}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls("grid grid-cols-2 gap-5", shouldSpanFull && "md:col-span-2")}
>
<div className={cls(
"flex flex-col justify-between gap-5 p-5 card rounded",
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
isEven && "order-2 md:order-1"
)}>
<span className="text-5xl md:text-6xl font-medium leading-tight truncate">{metric.value}</span>
<div className="flex flex-col gap-2">
<span className="text-xl md:text-2xl font-medium truncate">{metric.title}</span>
<div className="w-full h-px bg-accent" />
<p className="text-base leading-tight truncate">{metric.description}</p>
</div>
</div>
<div className={cls(
"rounded overflow-hidden",
shouldSpanFull ? "aspect-square md:aspect-video" : "aspect-square",
isEven && "order-1 md:order-2"
)}>
<ImageOrVideo imageSrc={metric.imageSrc} videoSrc={metric.videoSrc} />
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
export default MetricsMediaCards;

View File

@@ -0,0 +1,62 @@
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import TextAnimation from "@/components/ui/TextAnimation";
type Metric = {
value: string;
description: string;
};
const MetricsMinimalCards = ({
tag,
title,
metrics,
}: {
tag: string;
title: string;
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8 w-content-width mx-auto">
<div className="flex flex-col gap-5">
<span className="px-3 py-1 w-fit text-sm card rounded md:hidden">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-3xl md:text-5xl font-medium leading-tight text-balance"
/>
</div>
<div className="w-full h-px bg-accent" />
<div className="flex flex-col md:flex-row md:items-start gap-8">
<span className="hidden md:block px-3 py-1 text-sm card rounded">{tag}</span>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={cls(
"grid grid-cols-1 gap-5 flex-1",
metrics.length >= 2 && "md:grid-cols-2"
)}
>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 md:p-8 aspect-video card rounded">
<span className="text-6xl md:text-8xl font-medium leading-none truncate">{metric.value}</span>
<div className="flex flex-col gap-5">
<div className="w-full h-px bg-accent" />
<p className="text-base md:text-lg leading-tight text-balance">{metric.description}</p>
</div>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
export default MetricsMinimalCards;

View File

@@ -0,0 +1,72 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type Metric = {
value: string;
description: string;
};
const MetricsSimpleCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
metrics,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
metrics: Metric[];
}) => (
<section aria-label="Metrics section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel carouselThreshold={3}>
{metrics.map((metric) => (
<div key={metric.value} className="flex flex-col justify-between gap-5 p-5 min-h-70 h-full card rounded">
<span className="text-7xl md:text-8xl font-medium leading-none truncate">{metric.value}</span>
<p className="text-base leading-tight text-balance">{metric.description}</p>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default MetricsSimpleCards;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type PricingPlan = {
tag: string;
price: string;
description: string;
features: string[];
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
};
const PricingCenteredCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col items-center gap-5 p-5 h-full card rounded text-center">
<span className="px-5 py-2 text-sm card rounded">{plan.tag}</span>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-base">{plan.description}</span>
</div>
<div className="flex flex-col gap-3 w-full">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="flex flex-col gap-3 w-full">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base text-left">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingCenteredCards;

View File

@@ -0,0 +1,105 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import { cls } from "@/lib/utils";
type PricingPlan = {
tag: string;
price: string;
description: string;
features: string[];
highlight?: string;
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
};
const PricingHighlightedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center w-content-width mx-auto gap-3 md:gap-2">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col h-full">
<div className={cls("px-5 py-2 text-sm", plan.highlight ? "text-center primary-button rounded-t text-primary-cta-text" : "invisible")}>
{plan.highlight || "placeholder"}
</div>
<div className={cls("flex flex-col items-center gap-5 p-5 flex-1 card text-center", plan.highlight ? "rounded-t-none rounded-b" : "rounded")}>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-xl font-medium">{plan.tag}</span>
</div>
<div className="h-px w-full bg-foreground/20" />
<div className="flex flex-col gap-3 w-full">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base text-left">{feature}</span>
</div>
))}
</div>
<div className="flex flex-col gap-3 w-full mt-auto">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingHighlightedCards;

View File

@@ -0,0 +1,99 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
type PricingPlan = {
tag: string;
price: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: string[];
};
const PricingLayeredCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<GridOrCarousel>
{plans.map((plan) => (
<div key={plan.tag} className="flex flex-col gap-3 p-3 h-full card rounded">
<div className="flex flex-col gap-3 p-5 secondary-button rounded">
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.tag}</span>
<div className="flex flex-col gap-1">
<span className="text-5xl font-medium">{plan.price}</span>
<span className="text-base">{plan.description}</span>
</div>
<div className="flex flex-col gap-3">
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-full" />
{plan.secondaryButton && <Button text={plan.secondaryButton.text} href={plan.secondaryButton.href} variant="secondary" className="w-full" />}
</div>
</div>
<div className="flex flex-col gap-3 p-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base leading-tight">{feature}</span>
</div>
))}
</div>
</div>
))}
</GridOrCarousel>
</motion.div>
</div>
</section>
);
export default PricingLayeredCards;

View File

@@ -0,0 +1,95 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type PricingPlan = {
tag: string;
price: string;
period: string;
features: string[];
primaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const PricingMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
plans,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
plans: PricingPlan[];
}) => (
<section aria-label="Pricing section" className="py-20">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-3 md:gap-2 w-content-width mx-auto">
<span className="px-3 py-1 text-sm card rounded">{tag}</span>
<TextAnimation
text={title}
variant="slide-up"
tag="h2"
className="text-6xl font-medium text-center text-balance"
/>
<TextAnimation
text={description}
variant="slide-up"
tag="p"
className="md:max-w-6/10 text-lg leading-tight text-center"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-1 md:mt-2">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate delay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 w-content-width mx-auto">
{plans.map((plan) => (
<motion.div
key={plan.tag}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row gap-5 md:gap-10 p-5 card rounded"
>
<div className="w-full md:w-1/2 aspect-square md:aspect-4/3 rounded overflow-hidden">
<ImageOrVideo imageSrc={plan.imageSrc} videoSrc={plan.videoSrc} />
</div>
<div className="flex flex-col justify-center gap-5 w-full md:w-1/2">
<span className="px-3 py-1 w-fit text-sm card rounded">{plan.price}{plan.period}</span>
<h3 className="text-4xl md:text-5xl font-medium truncate">{plan.tag}</h3>
<div className="flex flex-col gap-3">
{plan.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-sm leading-tight">{feature}</span>
</div>
))}
</div>
<Button text={plan.primaryButton.text} href={plan.primaryButton.href} variant="primary" className="w-fit mt-1" />
</div>
</motion.div>
))}
</div>
</div>
</section>
);
export default PricingMediaCards;

Some files were not shown because too many files have changed in this diff Show More