Initial commit

This commit is contained in:
2026-04-10 03:20:23 +03:00
commit 24326609e3
49 changed files with 7411 additions and 0 deletions

4
.env Normal file
View File

@@ -0,0 +1,4 @@
NEXT_PUBLIC_API_URL=https://dev.api.webild.io
NEXT_PUBLIC_PROJECT_ID=bbf72c0b-a095-4fae-87f9-06c8e9d7ee05

View File

@@ -0,0 +1,43 @@
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
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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local

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);"
}
}

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
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,
],
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"
}
}
}
}

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>Webild Components</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3478
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

267
registry.json Normal file
View File

@@ -0,0 +1,267 @@
{
"componentRegistry": {
"navbar": [
{
"name": "NavbarCentered",
"import": "import NavbarCentered from '@/components/ui/NavbarCentered';",
"path": "@/components/ui/NavbarCentered",
"description": "Centered navigation bar with logo, links and buttons",
"propsSchema": {
"logo": "string",
"navItems": "{ name: string; href: string }[]",
"ctaButton": "{ text: string; href: string }"
}
}
]
},
"sectionRegistry": {
"hero": [
{
"name": "HeroBillboard",
"import": "import HeroBillboard from '@/components/sections/hero/HeroBillboard';",
"path": "@/components/sections/hero/HeroBillboard",
"description": "Full-width centered hero with media below text content",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton": "{ text: string; href: string }",
"secondaryButton": "{ text: string; href: string }",
"imageSrc": "string \u2014 image URL (provide imageSrc OR videoSrc, not both)",
"videoSrc": "string \u2014 video URL (provide imageSrc OR videoSrc, not both)"
}
},
{
"name": "HeroBillboardGallery",
"import": "import HeroBillboardGallery from '@/components/sections/hero/HeroBillboardGallery';",
"path": "@/components/sections/hero/HeroBillboardGallery",
"description": "Full-width centered hero with image gallery below",
"propsSchema": {
"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 })[]"
}
},
{
"name": "HeroSplit",
"import": "import HeroSplit from '@/components/sections/hero/HeroSplit';",
"path": "@/components/sections/hero/HeroSplit",
"description": "Split layout hero with text on left, media on right",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton": "{ text: string; href: string }",
"secondaryButton": "{ text: string; href: string }",
"imageSrc": "string \u2014 image URL (provide imageSrc OR videoSrc, not both)",
"videoSrc": "string \u2014 video URL (provide imageSrc OR videoSrc, not both)"
}
}
],
"about": [
{
"name": "AboutFeaturesSplit",
"import": "import AboutFeaturesSplit from '@/components/sections/about/AboutFeaturesSplit';",
"path": "@/components/sections/about/AboutFeaturesSplit",
"description": "About section with feature list on left, media on right",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "{ icon: LucideIcon (import from lucide-react, pass as component reference e.g. icon={Star} not icon=\"Star\"); title: string; description: string }[]",
"imageSrc": "string \u2014 image URL (provide imageSrc OR videoSrc, not both)",
"videoSrc": "string \u2014 video URL (provide imageSrc OR videoSrc, not both)"
}
},
{
"name": "AboutMediaOverlay",
"import": "import AboutMediaOverlay from '@/components/sections/about/AboutMediaOverlay';",
"path": "@/components/sections/about/AboutMediaOverlay",
"description": "About section with text overlaid on media background",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"imageSrc": "string \u2014 image URL (provide imageSrc OR videoSrc, not both)",
"videoSrc": "string \u2014 video URL (provide imageSrc OR videoSrc, not both)"
}
},
{
"name": "AboutTestimonial",
"import": "import AboutTestimonial from '@/components/sections/about/AboutTestimonial';",
"path": "@/components/sections/about/AboutTestimonial",
"description": "Testimonial section with quote and author info",
"propsSchema": {
"tag": "string",
"quote": "string",
"author": "string",
"role": "string",
"imageSrc": "string \u2014 image URL (provide imageSrc OR videoSrc, not both)",
"videoSrc": "string \u2014 video URL (provide imageSrc OR videoSrc, not both)"
}
},
{
"name": "AboutTextSplit",
"import": "import AboutTextSplit from '@/components/sections/about/AboutTextSplit';",
"path": "@/components/sections/about/AboutTextSplit",
"description": "Text-only about section with title on left, descriptions on right",
"propsSchema": {
"title": "string",
"descriptions": "string[]",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }"
}
}
],
"features": [
{
"name": "FeaturesMediaCards",
"import": "import FeaturesMediaCards from '@/components/sections/features/FeaturesMediaCards';",
"path": "@/components/sections/features/FeaturesMediaCards",
"description": "Features section with media cards in grid or carousel",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "({ title: string; description: string } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
},
{
"name": "FeaturesAlternatingSplit",
"import": "import FeaturesAlternatingSplit from '@/components/sections/features/FeaturesAlternatingSplit';",
"path": "@/components/sections/features/FeaturesAlternatingSplit",
"description": "Features section with numbered alternating left/right split cards",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "({ title: string; description: string; primaryButton?: { text: string; href: string }; secondaryButton?: { text: string; href: string } } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
},
{
"name": "FeaturesLabeledList",
"import": "import FeaturesLabeledList from '@/components/sections/features/FeaturesLabeledList';",
"path": "@/components/sections/features/FeaturesLabeledList",
"description": "Features section with large labels and bullet-separated item lists",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "{ label: string; title: string; bullets: string[]; primaryButton: { text: string; href: string }; secondaryButton: { text: string; href: string } }[]"
}
},
{
"name": "FeaturesComparison",
"import": "import FeaturesComparison from '@/components/sections/features/FeaturesComparison';",
"path": "@/components/sections/features/FeaturesComparison",
"description": "Features comparison section with negative and positive lists side by side",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"negativeItems": "string[]",
"positiveItems": "string[]"
}
},
{
"name": "FeaturesDetailedSteps",
"import": "import FeaturesDetailedSteps from '@/components/sections/features/FeaturesDetailedSteps';",
"path": "@/components/sections/features/FeaturesDetailedSteps",
"description": "Features section with detailed step cards including tag, title, subtitle, description, and tilted media",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"steps": "({ tag: string; title: string; subtitle: string; description: string } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
},
{
"name": "FeaturesTaggedCards",
"import": "import FeaturesTaggedCards from '@/components/sections/features/FeaturesTaggedCards';",
"path": "@/components/sections/features/FeaturesTaggedCards",
"description": "Features section with media cards featuring tag overlay on image and content card below",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "({ tag: string; title: string; description: string; primaryButton?: { text: string; href: string }; secondaryButton?: { text: string; href: string } } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
},
{
"name": "FeaturesArrowCards",
"import": "import FeaturesArrowCards from '@/components/sections/features/FeaturesArrowCards';",
"path": "@/components/sections/features/FeaturesArrowCards",
"description": "Features section with media cards featuring title, tags, and arrow icon for navigation",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "({ title: string; tags: string[] } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
},
{
"name": "FeaturesDetailedCards",
"import": "import FeaturesDetailedCards from '@/components/sections/features/FeaturesDetailedCards';",
"path": "@/components/sections/features/FeaturesDetailedCards",
"description": "Features section with horizontal split cards featuring title, author, tags, description, and media",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "({ title: string; description: string; tags: string[] } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
},
{
"name": "FeaturesDualMedia",
"import": "import FeaturesDualMedia from '@/components/sections/features/FeaturesDualMedia';",
"path": "@/components/sections/features/FeaturesDualMedia",
"description": "Features section with icon cards featuring two media items side by side",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "{ icon: LucideIcon (import from lucide-react, pass as component reference e.g. icon={Star} not icon=\"Star\"); title: string; description: string; mediaItems: [({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }), ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })] }[]"
}
},
{
"name": "FeaturesMediaCarousel",
"import": "import FeaturesMediaCarousel from '@/components/sections/features/FeaturesMediaCarousel';",
"path": "@/components/sections/features/FeaturesMediaCarousel",
"description": "Carousel of cards with media background and text overlay at bottom",
"propsSchema": {
"tag": "string",
"title": "string",
"description": "string",
"primaryButton?": "{ text: string; href: string }",
"secondaryButton?": "{ text: string; href: string }",
"items": "({ title: string; description: string; buttonIcon: LucideIcon (import from lucide-react, pass as component reference e.g. icon={Star} not icon=\"Star\"); buttonHref?: string; buttonOnClick?: () => void } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }))[]"
}
}
]
}
}

124
src/App.tsx Normal file
View File

@@ -0,0 +1,124 @@
import AboutMediaOverlay from '@/components/sections/about/AboutMediaOverlay';
import FeaturesDetailedCards from '@/components/sections/features/FeaturesDetailedCards';
import FeaturesDetailedSteps from '@/components/sections/features/FeaturesDetailedSteps';
import HeroBillboard from '@/components/sections/hero/HeroBillboard';
import NavbarCentered from '@/components/ui/NavbarCentered';
export default function App() {
return (
<>
<div id="nav" data-section="nav">
<NavbarCentered
logo="Golden Crust Bakery"
navItems={[
{
name: "Home",
id: "hero",
},
{
name: "Our Story",
id: "about",
},
{
name: "Our Process",
id: "process",
},
]}
ctaButton={{
text: "Order Online",
href: "#order",
}}
/>
</div>
<div id="hero" data-section="hero">
<HeroBillboard
tag="Since 1984"
title="Handcrafted Bread, Baked Daily"
description="We use time-honored techniques and local, organic ingredients to bring the warmth of our oven to your home."
primaryButton={{
text: "Explore Our Breads",
href: "#process",
}}
secondaryButton={{
text: "Visit Our Shop",
href: "#order",
}}
imageSrc="https://images.unsplash.com/photo-1552965118-62bc976281dc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHwyMHx8YmFrZXJ5JTIwcHJvZHVjdHMlMjBiYWNrZ3JvdW5kJTIwYWVzdGhldGljfGVufDF8MHx8fDE3NzU3ODA0MTJ8MA&ixlib=rb-4.1.0&q=80&w=1080"
/>
</div>
<div id="about" data-section="about">
<AboutMediaOverlay
tag="Our Story"
title="True to Tradition"
description="At Golden Crust, we believe in the power of patience. Our sourdough culture is over 40 years old, nurtured by our master bakers to create flavors you won't find anywhere else."
primaryButton={{
text: "Our Methods",
href: "#process",
}}
imageSrc="https://images.unsplash.com/photo-1738598669303-9e6c217ba01d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHw2fHxwcm9mZXNzaW9uYWwlMjBiYWtlciUyMHByZXBhcmluZyUyMGJyZWFkfGVufDF8MHx8fDE3NzU3ODA0MTJ8MA&ixlib=rb-4.1.0&q=80&w=1080"
/>
</div>
<div id="process" data-section="process">
<FeaturesDetailedSteps
tag="Our Craft"
title="The Golden Crust Process"
description="Quality takes time. Here is how we ensure every loaf meets our standard."
steps={[
{
tag: "Step 1",
title: "Slow Fermentation",
subtitle: "24 hours",
description: "Our signature sourdough undergoes a cold fermentation for 24 hours to develop a complex, tangy flavor profile.",
imageSrc: "https://images.unsplash.com/photo-1609970972657-b10fa33c0f48?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHwxMXx8Y2xvc2UlMjB1cCUyMGRvdWdoJTIwdGV4dHVyZXxlbnwxfDB8fHwxNzc1NzgwNDEzfDA&ixlib=rb-4.1.0&q=80&w=1080",
},
{
tag: "Step 2",
title: "Stone Baking",
subtitle: "Hand-crafted",
description: "Every loaf is hand-shaped and baked in our traditional stone ovens, ensuring the perfect crust texture.",
imageSrc: "https://images.unsplash.com/photo-1772758631701-5c5f532f44cf?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHw0fHxiYWtpbmclMjBicmVhZCUyMGluJTIwc3RvbmUlMjBvdmVufGVufDF8MHx8fDE3NzU3ODA0MTN8MA&ixlib=rb-4.1.0&q=80&w=1080",
},
{
tag: "Step 3",
title: "Freshness Check",
subtitle: "Morning delivery",
description: "We hand-inspect every batch before it leaves our bakery to ensure you get only the finest artisan goods.",
imageSrc: "https://images.unsplash.com/photo-1611871679274-5242ed4c3cd0?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHwyOHx8ZGVsaWNpb3VzJTIwaGVhbHRoeSUyMHNvdXJkb3VnaCUyMGJyZWFkfGVufDF8MHx8fDE3NzU3ODA0MTR8MA&ixlib=rb-4.1.0&q=80&w=1080",
},
]}
/>
</div>
<div id="features" data-section="features">
<FeaturesDetailedCards
tag="Our Breads"
title="Freshly Baked Every Morning"
description="Experience the aroma and taste of bread crafted with passion."
items={[
{
title: "Rustic Sourdough",
description: "A classic, tangy loaf with a chewy interior and perfectly crisp crust.",
tags: [
"Bestseller",
"Organic",
],
imageSrc: "https://images.unsplash.com/photo-1712603163675-84c3a4192038?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHwyN3x8ZGVsaWNpb3VzJTIwaGVhbHRoeSUyMHNvdXJkb3VnaCUyMGJyZWFkfGVufDF8MHx8fDE3NzU3ODA0MTR8MA&ixlib=rb-4.1.0&q=80&w=1080",
},
{
title: "Multigrain Boule",
description: "Packed with flax, sunflower, and sesame seeds for a hearty crunch.",
tags: [
"Healthy",
"Nutritious",
],
imageSrc: "https://images.unsplash.com/photo-1596483887964-4b1cc516d9d0?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w4Mzc5ODl8MHwxfHNlYXJjaHwyMXx8ZGVsaWNpb3VzJTIwaGVhbHRoeSUyMHNvdXJkb3VnaCUyMGJyZWFkfGVufDF8MHx8fDE3NzU3ODA0MTR8MA&ixlib=rb-4.1.0&q=80&w=1080",
},
]}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,80 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutFeaturesSplitProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: { icon: 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-4 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-6">
<div className="flex flex-col justify-center gap-6 p-6 w-full md:w-4/10 2xl:w-3/10 card rounded">
{items.map((item, index) => (
<div key={item.title}>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-center shrink-0 mb-1 h-10 w-10 primary-button rounded">
<item.icon 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-6 border-b border-accent/40" />
)}
</div>
))}
</div>
<div className="p-4 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-4 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-6 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 h-14 md:h-16 aspect-square 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-4 md:gap-6 py-8 md:py-4 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-6 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-4">
{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,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-4 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-4 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-6 md:gap-12 p-6 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 h-8 w-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-4 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-4 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 h-full gap-4 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 flex-1 gap-4 p-5 card rounded">
<h3 className="text-xl md:text-2xl font-medium leading-tight">{item.title}</h3>
<div className="flex items-center justify-between gap-4">
<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,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-4 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-4"
>
<div className="flex flex-col gap-4 p-6 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="truncate">{item}</span>
</div>
))}
</div>
<div className="flex flex-col gap-4 p-6 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="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-4 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-4">
{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-6 md:gap-10 p-6 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-4">
<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-4 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-4">
{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-6 md:gap-12 p-6 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-4 md:gap-6">
<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-6 bg-accent/20" />
<div className="flex flex-col gap-2 md:gap-4">
<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,99 @@
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";
type FeatureItem = {
icon: 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-4 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 = item.icon;
return (
<div key={item.title} className="flex flex-col h-full gap-5 p-5 card rounded">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-center mb-1 h-15 w-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,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-4 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-4 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-6 md:gap-12 p-6 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-4">
<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-4 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-4 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-4 p-4 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,98 @@
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";
type FeatureItem = {
title: string;
description: string;
buttonIcon: 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 = 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-4 p-6">
<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 h-8 aspect-square primary-button rounded cursor-pointer"
>
<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-4 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,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-4 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 h-full gap-4 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-4 right-4 px-3 py-1 text-sm card rounded">{item.tag}</span>
</div>
<div className="flex flex-col flex-1 gap-2 p-6 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-4 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,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 max-md:justify-center gap-4 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-4 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-square md:aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboard;

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 HeroBillboardGalleryProps {
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 HeroBillboardGallery = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: HeroBillboardGalleryProps) => {
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-4 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-4 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 HeroBillboardGallery;

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 md:gap-4">
<span className="px-3 py-1 mb-1 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-4 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-4 card rounded overflow-hidden"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
</div>
</section>
);
};
export default HeroSplit;

View File

@@ -0,0 +1,43 @@
import { Link } from "react-router-dom";
import { motion } from "motion/react";
import { cls } from "@/lib/utils";
import { useButtonClick } from "@/hooks/useButtonClick";
interface ButtonProps {
text: string;
variant?: "primary" | "secondary";
href?: string;
onClick?: () => void;
animate?: boolean;
delay?: number;
className?: string;
}
const Button = ({ text, variant = "primary", href, onClick, animate = false, delay = 0, className = "" }: ButtonProps) => {
const handleClick = useButtonClick(href, onClick);
const classes = cls(
"flex items-center justify-center h-9 px-6 text-sm rounded cursor-pointer",
variant === "primary" ? "primary-button text-primary-cta-text" : "secondary-button text-secondary-cta-text",
className
);
const button = href?.startsWith("/")
? <Link to={href} className={classes}>{text}</Link>
: <button onClick={handleClick} className={classes}>{text}</button>;
if (!animate) return button;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.6, delay, ease: "easeOut" }}
>
{button}
</motion.div>
);
};
export default Button;

View File

@@ -0,0 +1,64 @@
import { Children, type ReactNode } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cls } from "@/lib/utils";
import { useCarouselControls } from "@/hooks/useCarouselControls";
interface GridOrCarouselProps {
children: ReactNode;
carouselThreshold?: 2 | 3 | 4;
}
const GridOrCarousel = ({ children, carouselThreshold = 3 }: GridOrCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
const { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress } = useCarouselControls(emblaApi);
const items = Children.toArray(children);
const count = items.length;
if (count <= carouselThreshold) {
return (
<div className={cls(
"grid grid-cols-1 gap-4 mx-auto w-content-width",
count === 2 && "md:grid-cols-2",
count === 3 && "md:grid-cols-3",
count === 4 && "md:grid-cols-4"
)}>
{children}
</div>
);
}
return (
<div className="flex flex-col gap-6 w-full">
<div ref={emblaRef} className="overflow-hidden w-full cursor-grab">
<div className="flex gap-4">
<div className="shrink-0 w-carousel-padding" />
{items.map((child, i) => (
<div key={i} className={cls("shrink-0", carouselThreshold === 2 ? "w-carousel-item-2" : carouselThreshold === 3 ? "w-carousel-item-3" : "w-carousel-item-4")}>{child}</div>
))}
<div className="shrink-0 w-carousel-padding" />
</div>
</div>
<div className="flex w-full">
<div className="shrink-0 w-carousel-padding-controls" />
<div className="flex justify-between items-center w-full">
<div className="relative h-2 w-1/2 card rounded overflow-hidden">
<div className="absolute top-0 bottom-0 -left-full w-full primary-button rounded" style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }} />
</div>
<div className="flex items-center gap-3">
<button onClick={scrollPrev} disabled={prevDisabled} type="button" aria-label="Previous" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
<ChevronLeft className="h-2/5 aspect-square text-secondary-cta-text" />
</button>
<button onClick={scrollNext} disabled={nextDisabled} type="button" aria-label="Next" className="flex items-center justify-center h-8 aspect-square secondary-button rounded cursor-pointer disabled:opacity-50">
<ChevronRight className="h-2/5 aspect-square text-secondary-cta-text" />
</button>
</div>
</div>
<div className="shrink-0 w-carousel-padding-controls" />
</div>
</div>
);
};
export default GridOrCarousel;

View File

@@ -0,0 +1,41 @@
import { cls } from "@/lib/utils";
interface ImageOrVideoProps {
imageSrc?: string;
videoSrc?: string;
className?: string;
}
const ImageOrVideo = ({
imageSrc,
videoSrc,
className = "",
}: ImageOrVideoProps) => {
if (videoSrc) {
return (
<video
src={videoSrc}
aria-label={videoSrc}
className={cls("w-full h-full min-h-0 object-cover rounded", className)}
autoPlay
loop
muted
playsInline
/>
);
}
if (imageSrc) {
return (
<img
src={imageSrc}
alt={imageSrc}
className={cls("w-full h-full min-h-0 object-cover rounded", className)}
/>
);
}
return null;
};
export default ImageOrVideo;

View File

@@ -0,0 +1,76 @@
import { Children, useCallback, useEffect, useState, type ReactNode } from "react";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaCarouselType } from "embla-carousel";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cls } from "@/lib/utils";
interface LoopCarouselProps {
children: ReactNode;
}
const LoopCarousel = ({ children }: LoopCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
const [selectedIndex, setSelectedIndex] = useState(0);
const items = Children.toArray(children);
const onSelect = useCallback((api: EmblaCarouselType) => {
setSelectedIndex(api.selectedScrollSnap());
}, []);
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("select", onSelect).on("reInit", onSelect);
return () => {
emblaApi.off("select", onSelect).off("reInit", onSelect);
};
}, [emblaApi, onSelect]);
return (
<div className="relative w-full md:w-content-width mx-auto">
<div ref={emblaRef} className="overflow-hidden w-full mask-fade-x">
<div className="flex w-full">
{items.map((child, index) => (
<div key={index} className="shrink-0 w-content-width md:w-[clamp(18rem,50vw,48rem)] mr-3 md:mr-6">
<div
className={cls(
"transition-all duration-500 ease-out",
selectedIndex === index ? "opacity-100 scale-100" : "opacity-70 scale-90"
)}
>
{child}
</div>
</div>
))}
</div>
</div>
<div className="absolute inset-y-0 left-0 right-0 flex items-center justify-between w-content-width mx-auto pointer-events-none">
<button
onClick={scrollPrev}
type="button"
aria-label="Previous slide"
className="flex items-center justify-center h-8 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
>
<ChevronLeft className="h-2/5 aspect-square text-primary-cta-text" />
</button>
<button
onClick={scrollNext}
type="button"
aria-label="Next slide"
className="flex items-center justify-center h-8 aspect-square primary-button rounded cursor-pointer pointer-events-auto"
>
<ChevronRight className="h-2/5 aspect-square text-primary-cta-text" />
</button>
</div>
</div>
);
};
export default LoopCarousel;

View File

@@ -0,0 +1,109 @@
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Link } from "react-router-dom";
import { Plus, ArrowRight } from "lucide-react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
interface NavbarCenteredProps {
logo: string;
navItems: { name: string; href: string }[];
ctaButton: { text: string; href: string };
}
const NavbarCentered = ({ logo, navItems, ctaButton }: NavbarCenteredProps) => {
const [isScrolled, setIsScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => setIsScrolled(window.scrollY > 50);
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<>
<nav
className={cls(
"fixed z-1000 top-0 left-0 w-full transition-all duration-500 ease-in-out",
isScrolled ? "h-15 bg-background/80 backdrop-blur-sm" : "h-20 bg-background/0 backdrop-blur-0"
)}
>
<div className="relative flex items-center justify-between h-full w-content-width mx-auto">
<Link to="/" className="text-xl font-medium text-foreground">{logo}</Link>
<div className="hidden md:flex absolute left-1/2 items-center gap-6 -translate-x-1/2">
{navItems.map((item) => (
<Link key={item.name} to={item.href} className="text-base text-foreground hover:opacity-70 transition-opacity">
{item.name}
</Link>
))}
</div>
<div className="hidden md:block">
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" />
</div>
<button
className="flex md:hidden items-center justify-center shrink-0 h-8 w-8 bg-foreground rounded cursor-pointer"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Toggle menu"
aria-expanded={menuOpen}
>
<Plus
className={cls("w-1/2 h-1/2 text-background transition-transform duration-300", menuOpen ? "rotate-45" : "rotate-0")}
strokeWidth={1.5}
/>
</button>
</div>
</nav>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ y: "-135%" }}
animate={{ y: 0 }}
exit={{ y: "-135%" }}
transition={{ type: "spring", damping: 26, stiffness: 170 }}
className="md:hidden fixed z-1000 top-3 left-3 right-3 p-6 card rounded"
>
<div className="flex items-center justify-between mb-6">
<p className="text-xl text-foreground">Menu</p>
<button
className="flex items-center justify-center shrink-0 h-8 w-8 bg-foreground rounded cursor-pointer"
onClick={() => setMenuOpen(false)}
aria-label="Close menu"
>
<Plus className="w-1/2 h-1/2 text-background rotate-45" strokeWidth={1.5} />
</button>
</div>
<div className="flex flex-col gap-4">
{navItems.map((item, index) => (
<div key={item.name}>
<Link
to={item.href}
onClick={() => setMenuOpen(false)}
className="flex items-center justify-between py-2 text-base font-medium text-foreground"
>
{item.name}
<ArrowRight className="h-4 w-4 text-foreground" strokeWidth={1.5} />
</Link>
{index < navItems.length - 1 && (
<div className="h-px bg-linear-to-r from-transparent via-foreground/20 to-transparent" />
)}
</div>
))}
</div>
<div className="mt-6">
<Button text={ctaButton.text} href={ctaButton.href} variant="primary" className="w-full" />
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default NavbarCentered;

View File

@@ -0,0 +1,60 @@
import { motion } from "motion/react";
type Variant = "slide-up" | "fade-blur" | "fade";
interface TextAnimationProps {
text: string;
variant: Variant;
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
className?: string;
}
const VARIANTS = {
"slide-up": {
hidden: { opacity: 0, y: "50%" },
visible: { opacity: 1, y: 0 },
},
"fade-blur": {
hidden: { opacity: 0, filter: "blur(10px)" },
visible: { opacity: 1, filter: "blur(0px)" },
},
"fade": {
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
};
const EASING: Record<Variant, [number, number, number, number]> = {
"slide-up": [0.25, 0.46, 0.45, 0.94],
"fade-blur": [0.45, 0, 0.55, 1],
"fade": [0.45, 0, 0.55, 1],
};
const TextAnimation = ({ text, variant, tag = "p", className = "" }: TextAnimationProps) => {
const Tag = motion[tag] as typeof motion.p;
const words = text.split(" ");
return (
<Tag
className={className}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-20%" }}
transition={{ staggerChildren: 0.04 }}
>
{words.map((word, i) => (
<motion.span
key={i}
className="inline-block"
variants={VARIANTS[variant]}
transition={{ duration: 0.6, ease: EASING[variant] }}
>
{word}
{i < words.length - 1 && "\u00A0"}
</motion.span>
))}
</Tag>
);
};
export default TextAnimation;

View File

@@ -0,0 +1,51 @@
import { useNavigate, useLocation } from "react-router-dom";
export const useButtonClick = (href?: string, onClick?: () => void) => {
const navigate = useNavigate();
const location = useLocation();
const scrollToElement = (sectionId: string, delay: number = 100) => {
setTimeout(() => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, delay);
};
const handleClick = () => {
if (href) {
const isExternalLink = /^(https?:\/\/|www\.)/.test(href);
if (isExternalLink) {
window.open(
href.startsWith("www.") ? `https://${href}` : href,
"_blank",
"noopener,noreferrer"
);
} else if (href.startsWith("/")) {
const [path, hash] = href.split("#");
if (path !== location.pathname) {
navigate(path);
if (hash) {
setTimeout(() => {
window.location.hash = hash;
scrollToElement(hash, 100);
}, 100);
}
} else if (hash) {
window.location.hash = hash;
scrollToElement(hash, 50);
}
} else if (href.startsWith("#")) {
scrollToElement(href.slice(1), 50);
} else {
scrollToElement(href, 50);
}
}
onClick?.();
};
return handleClick;
};

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from "react";
import type { EmblaCarouselType } from "embla-carousel";
export const useCarouselControls = (emblaApi: EmblaCarouselType | undefined) => {
const [prevDisabled, setPrevDisabled] = useState(true);
const [nextDisabled, setNextDisabled] = useState(true);
const [scrollProgress, setScrollProgress] = useState(0);
const scrollPrev = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollNext();
}, [emblaApi]);
const onSelect = useCallback((api: EmblaCarouselType) => {
setPrevDisabled(!api.canScrollPrev());
setNextDisabled(!api.canScrollNext());
}, []);
const onScroll = useCallback((api: EmblaCarouselType) => {
const progress = Math.max(0, Math.min(1, api.scrollProgress()));
setScrollProgress(progress * 100);
}, []);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
onScroll(emblaApi);
emblaApi.on("reInit", onSelect).on("select", onSelect);
emblaApi.on("reInit", onScroll).on("scroll", onScroll);
return () => {
emblaApi.off("reInit", onSelect).off("select", onSelect);
emblaApi.off("reInit", onScroll).off("scroll", onScroll);
};
}, [emblaApi, onSelect, onScroll]);
return { prevDisabled, nextDisabled, scrollPrev, scrollNext, scrollProgress };
};

173
src/index.css Normal file
View File

@@ -0,0 +1,173 @@
@import "tailwindcss";
@import "./styles/masks.css";
@import "./styles/animations.css";
:root {
/* @colorThemes/lightTheme/grayBlueAccent */
--background: #f6f0e9;
--card: #efe7dd;
--foreground: #2b180a;
--primary-cta: #2b180a;
--primary-cta-text: #f6f0e9;
--secondary-cta: #efe7dd;
--secondary-cta-text: #2b180a;
--accent: #94877c;
--background-accent: #afa094;
/* @layout/border-radius/rounded */
--radius: 0.5rem;
/* @layout/content-width/medium */
--width-content-width: clamp(40rem, 80vw, 100rem);
/* @utilities/masks */
--vw-1_5: 1.5vw;
--width-x-padding-mask-fade: 5vw;
/* @layout/carousel */
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
--width-carousel-item-2: calc(var(--width-content-width) / 2 - var(--vw-1_5) / 2);
--width-carousel-item-3: calc(var(--width-content-width) / 3 - var(--vw-1_5) / 3 * 2);
--width-carousel-item-4: calc(var(--width-content-width) / 4 - var(--vw-1_5) / 4 * 3);
/* @typography/text-sizing/medium */
--text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
--text-xs: clamp(0.54rem, 0.72vw, 0.72rem);
--text-sm: clamp(0.615rem, 0.82vw, 0.82rem);
--text-base: clamp(0.69rem, 0.92vw, 0.92rem);
--text-lg: clamp(0.75rem, 1vw, 1rem);
--text-xl: clamp(0.825rem, 1.1vw, 1.1rem);
--text-2xl: clamp(0.975rem, 1.3vw, 1.3rem);
--text-3xl: clamp(1.2rem, 1.6vw, 1.6rem);
--text-4xl: clamp(1.5rem, 2vw, 2rem);
--text-5xl: clamp(2.025rem, 2.75vw, 2.75rem);
--text-6xl: clamp(2.475rem, 3.3vw, 3.3rem);
--text-7xl: clamp(3rem, 4vw, 4rem);
--text-8xl: clamp(3.5rem, 4.5vw, 4.5rem);
--text-9xl: clamp(5.25rem, 7vw, 7rem);
}
/* @typography/text-sizing/medium (mobile) */
@media (max-width: 768px) {
:root {
--text-2xs: 2.5vw;
--text-xs: 2.75vw;
--text-sm: 3vw;
--text-base: 3.25vw;
--text-lg: 3.5vw;
--text-xl: 4.25vw;
--text-2xl: 5vw;
--text-3xl: 6vw;
--text-4xl: 7vw;
--text-5xl: 7.5vw;
--text-6xl: 8.5vw;
--text-7xl: 10vw;
--text-8xl: 12vw;
--text-9xl: 14vw;
--width-content-width: 80vw;
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
--width-carousel-item-2: var(--width-content-width);
--width-carousel-item-3: var(--width-content-width);
--width-carousel-item-4: var(--width-content-width);
}
}
@theme inline {
/* Colors */
--color-background: var(--background);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-primary-cta: var(--primary-cta);
--color-primary-cta-text: var(--primary-cta-text);
--color-secondary-cta: var(--secondary-cta);
--color-secondary-cta-text: var(--secondary-cta-text);
--color-accent: var(--accent);
--color-background-accent: var(--background-accent);
/* Fonts */
--font-sans: "Inter", sans-serif;
--font-mono: monospace;
/* Border Radius */
--radius: var(--radius);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
/* Width */
--width-content-width: var(--width-content-width);
--width-carousel-padding: var(--width-carousel-padding);
--width-carousel-padding-controls: var(--width-carousel-padding-controls);
--width-carousel-item-2: var(--width-carousel-item-2);
--width-carousel-item-3: var(--width-carousel-item-3);
--width-carousel-item-4: var(--width-carousel-item-4);
/* Typography */
--text-2xs: var(--text-2xs);
--text-xs: var(--text-xs);
--text-sm: var(--text-sm);
--text-base: var(--text-base);
--text-lg: var(--text-lg);
--text-xl: var(--text-xl);
--text-2xl: var(--text-2xl);
--text-3xl: var(--text-3xl);
--text-4xl: var(--text-4xl);
--text-5xl: var(--text-5xl);
--text-6xl: var(--text-6xl);
--text-7xl: var(--text-7xl);
--text-8xl: var(--text-8xl);
--text-9xl: var(--text-9xl);
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 1) rgba(255, 255, 255, 0);
}
html {
overscroll-behavior: none;
overscroll-behavior-y: none;
}
body {
margin: 0;
background-color: var(--background);
color: var(--foreground);
font-family: "Inter", sans-serif;
position: relative;
min-height: 100vh;
overscroll-behavior: none;
overscroll-behavior-y: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Inter", sans-serif;
}
/* WEBILD_CARD_STYLE */
/* @cards/solid */
.card {
background: var(--color-card);
}
/* WEBILD_PRIMARY_BUTTON */
/* @primaryButtons/flat */
.primary-button {
background: var(--color-primary-cta);
}
/* WEBILD_SECONDARY_BUTTON */
/* @secondaryButtons/glass */
.secondary-button {
backdrop-filter: blur(8px);
background: linear-gradient(to bottom right, color-mix(in srgb, var(--color-secondary-cta) 80%, transparent), var(--color-secondary-cta));
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
border: 1px solid var(--color-secondary-cta);
}

10
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,10 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: (string | undefined | null | false)[]) {
return inputs.filter(Boolean).join(" ");
}
export function cls(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

158
src/styles/animations.css Normal file
View File

@@ -0,0 +1,158 @@
/* @utilities/animations */
@keyframes pulsate {
0% {
box-shadow: 0 0 0 0 var(--accent);
transform: scale(0.9);
}
50% {
transform: scale(1);
}
100% {
box-shadow: 0 0 20px 10px transparent;
transform: scale(0.9);
}
}
@keyframes fadeInOpacity {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInTranslate {
from {
transform: translateY(0.75vh);
}
to {
transform: translateY(0vh);
}
}
@keyframes aurora {
from {
background-position: 50% 50%, 50% 50%;
}
to {
background-position: 350% 50%, 350% 50%;
}
}
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
@keyframes marquee-vertical-reverse {
from {
transform: translateY(-50%);
}
to {
transform: translateY(0);
}
}
@keyframes marquee-horizontal {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes marquee-horizontal-reverse {
from {
transform: translateX(-50%);
}
to {
transform: translateX(0);
}
}
@keyframes orbit {
from {
transform: rotate(var(--initial-position, 0deg)) translateX(var(--translate-position, 120px))
rotate(calc(-1 * var(--initial-position, 0deg)));
}
to {
transform: rotate(calc(var(--initial-position, 0deg) + 360deg))
translateX(var(--translate-position, 120px))
rotate(calc(-1 * (var(--initial-position, 0deg) + 360deg)));
}
}
@keyframes map-dot-pulse {
0%,
100% {
transform: scale(0.4);
opacity: 0.6;
}
50% {
transform: scale(1.4);
opacity: 1;
}
}
/* Animation classes */
.animate-pulsate {
animation: pulsate 1.5s infinite;
}
.animation-container {
animation: fadeInOpacity 0.8s ease-in-out forwards, fadeInTranslate 0.6s forwards;
}
.animation-container-fade {
animation: fadeInOpacity 0.8s ease-in-out forwards;
}
.animate-spin-slow {
animation: spin-slow 15s linear infinite;
}
.animate-spin-reverse {
animation: spin-reverse 10s linear infinite;
}
.animate-marquee-vertical {
animation: marquee-vertical 40s linear infinite;
}
.animate-marquee-vertical-reverse {
animation: marquee-vertical-reverse 40s linear infinite;
}
.animate-marquee-horizontal {
animation: marquee-horizontal 15s linear infinite;
}
.animate-marquee-horizontal-reverse {
animation: marquee-horizontal-reverse 15s linear infinite;
}

61
src/styles/masks.css Normal file
View File

@@ -0,0 +1,61 @@
/* @utilities/masks */
.mask-fade-x {
mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
}
.mask-padding-x {
mask-image: linear-gradient(
to right,
transparent 0%,
black var(--width-x-padding-mask-fade),
black calc(100% - var(--width-x-padding-mask-fade)),
transparent 100%
);
}
.mask-fade-bottom {
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
}
.mask-fade-y {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black var(--vw-1_5),
black calc(100% - var(--vw-1_5)),
transparent 100%
);
}
.mask-fade-y-medium {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 20%,
black 80%,
transparent 100%
);
}
.mask-fade-bottom-large {
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
}
.mask-fade-bottom-long {
mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
}
.mask-fade-top-long {
mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
}
.mask-fade-xy {
mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%),
linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
mask-composite: intersect;
}
.mask-fade-top-overlay {
mask-image: linear-gradient(to bottom, transparent, black 60%);
}

30
tsconfig.app.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "es2023",
"lib": [
"ES2023",
"DOM"
],
"module": "esnext",
"types": [],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}

8
tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

5
vercel.json Normal file
View File

@@ -0,0 +1,5 @@
{
"installCommand": "npm ci",
"buildCommand": "npm run build",
"outputDirectory": ".next"
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})