This commit is contained in:
parent
fb5a3b8093
commit
58d8b661a8
1055 changed files with 116254 additions and 89 deletions
107
sites/vibes/src/components/BaseHead.astro
Normal file
107
sites/vibes/src/components/BaseHead.astro
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of the <BaseHead /> component.
|
||||
import '../styles/global.css';
|
||||
import { SITE_TITLE, SITE_METADATA } from '../consts';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const finalTitle = title || SITE_METADATA.title.default;
|
||||
const finalDescription = description || SITE_METADATA.description;
|
||||
const finalImage = image || SITE_METADATA.openGraph.images[0].url;
|
||||
const imageURL = new URL(finalImage, Astro.url);
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<!-- Preconnect to Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!-- Load Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Hubot+Sans:wght@400;500;600;700&family=Mona+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="robots" content={`${SITE_METADATA.robots.index ? 'index' : 'noindex'}, ${SITE_METADATA.robots.follow ? 'follow' : 'nofollow'}`} />
|
||||
<meta name="keywords" content={SITE_METADATA.keywords.join(', ')} />
|
||||
<meta name="author" content={SITE_METADATA.authors[0].name} />
|
||||
<meta name="creator" content={SITE_METADATA.creator} />
|
||||
<meta name="publisher" content={SITE_METADATA.publisher} />
|
||||
|
||||
<!-- Theme Script -->
|
||||
<script is:inline>
|
||||
// Get theme from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const initialTheme = savedTheme || systemTheme;
|
||||
|
||||
// Apply theme immediately to prevent flashing
|
||||
document.documentElement.classList.toggle('dark', initialTheme === 'dark');
|
||||
</script>
|
||||
|
||||
<!-- Favicon -->
|
||||
{SITE_METADATA.icons.icon.map((icon) => (
|
||||
<link
|
||||
rel="icon"
|
||||
type={icon.type}
|
||||
sizes={icon.sizes}
|
||||
href={icon.url}
|
||||
/>
|
||||
))}
|
||||
{SITE_METADATA.icons.apple.map((icon) => (
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes={icon.sizes}
|
||||
href={icon.url}
|
||||
/>
|
||||
))}
|
||||
{SITE_METADATA.icons.shortcut.map((icon) => (
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href={icon.url}
|
||||
/>
|
||||
))}
|
||||
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={SITE_TITLE}
|
||||
href={new URL('rss.xml', Astro.site)}
|
||||
/>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{finalTitle}</title>
|
||||
<meta name="title" content={finalTitle} />
|
||||
<meta name="description" content={finalDescription} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:site_name" content={SITE_METADATA.openGraph.siteName} />
|
||||
<meta property="og:title" content={finalTitle} />
|
||||
<meta property="og:description" content={finalDescription} />
|
||||
<meta property="og:image" content={imageURL} />
|
||||
<meta property="og:image:width" content={SITE_METADATA.openGraph.images[0].width.toString()} />
|
||||
<meta property="og:image:height" content={SITE_METADATA.openGraph.images[0].height.toString()} />
|
||||
<meta property="og:image:alt" content={SITE_METADATA.openGraph.images[0].alt} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content={SITE_METADATA.twitter.card} />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={finalTitle} />
|
||||
<meta property="twitter:description" content={finalDescription} />
|
||||
<meta property="twitter:image" content={imageURL} />
|
||||
<meta property="twitter:creator" content={SITE_METADATA.twitter.creator} />
|
||||
22
sites/vibes/src/components/elements/header-link.tsx
Normal file
22
sites/vibes/src/components/elements/header-link.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { type ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HeaderLinkProps = ComponentProps<"a">;
|
||||
|
||||
export default function HeaderLink({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: HeaderLinkProps) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={cn(
|
||||
"inline-block text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
44
sites/vibes/src/components/elements/theme-toggle.tsx
Normal file
44
sites/vibes/src/components/elements/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Moon, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ThemeToggle = ({ className }: { className?: string }) => {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial theme from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
const initialTheme = savedTheme || systemTheme;
|
||||
|
||||
setTheme(initialTheme as "light" | "dark");
|
||||
document.documentElement.classList.toggle("dark", initialTheme === "dark");
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === "light" ? "dark" : "light";
|
||||
setTheme(newTheme);
|
||||
document.documentElement.classList.toggle("dark");
|
||||
localStorage.setItem("theme", newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("size-8", className)}
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export { ThemeToggle };
|
||||
46
sites/vibes/src/components/icons/plus-signs.tsx
Normal file
46
sites/vibes/src/components/icons/plus-signs.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { type SVGProps, useId } from "react";
|
||||
|
||||
interface PlusSignsProps extends SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PlusSigns = ({ className, ...props }: PlusSignsProps) => {
|
||||
const GAP = 16;
|
||||
const STROKE_WIDTH = 1;
|
||||
const PLUS_SIZE = 6;
|
||||
const id = useId();
|
||||
const patternId = `plus-pattern-${id}`;
|
||||
|
||||
return (
|
||||
<svg width={GAP * 2} height={GAP * 2} className={className} {...props}>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
x="0"
|
||||
y="0"
|
||||
width={GAP}
|
||||
height={GAP}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<line
|
||||
x1={GAP / 2}
|
||||
y1={(GAP - PLUS_SIZE) / 2}
|
||||
x2={GAP / 2}
|
||||
y2={(GAP + PLUS_SIZE) / 2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<line
|
||||
x1={(GAP - PLUS_SIZE) / 2}
|
||||
y1={GAP / 2}
|
||||
x2={(GAP + PLUS_SIZE) / 2}
|
||||
y2={GAP / 2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${patternId})`} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
229
sites/vibes/src/components/problems/PermutationExplorer.tsx
Normal file
229
sites/vibes/src/components/problems/PermutationExplorer.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
interface Cycle {
|
||||
elements: number[];
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface PermutationData {
|
||||
permutation: number[];
|
||||
cycles: Cycle[];
|
||||
hasLongCycle: boolean;
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Generate all permutations of [1, 2, ..., n]
|
||||
function generatePermutations(n: number): number[][] {
|
||||
if (n === 1) return [[1]];
|
||||
|
||||
const result: number[][] = [];
|
||||
const smaller = generatePermutations(n - 1);
|
||||
|
||||
for (const perm of smaller) {
|
||||
for (let i = 0; i <= perm.length; i++) {
|
||||
const newPerm = [...perm.slice(0, i), n, ...perm.slice(i)];
|
||||
result.push(newPerm);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find cycles in a permutation
|
||||
function findCycles(permutation: number[]): Cycle[] {
|
||||
const n = permutation.length;
|
||||
const visited = new Array(n).fill(false);
|
||||
const cycles: Cycle[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!visited[i]) {
|
||||
const cycle: number[] = [];
|
||||
let current = i;
|
||||
|
||||
while (!visited[current]) {
|
||||
visited[current] = true;
|
||||
cycle.push(current + 1); // Convert to 1-indexed
|
||||
current = permutation[current] - 1; // Convert back to 0-indexed
|
||||
}
|
||||
|
||||
cycles.push({
|
||||
elements: cycle,
|
||||
length: cycle.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cycles.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
const PermutationExplorer: React.FC = () => {
|
||||
const [n, setN] = useState(3);
|
||||
const [filter, setFilter] = useState<'all' | 'long' | 'short'>('all');
|
||||
|
||||
const allPermutations = useMemo(() => {
|
||||
const perms = generatePermutations(n);
|
||||
return perms.map((perm, idx) => {
|
||||
const cycles = findCycles(perm);
|
||||
const hasLongCycle = cycles.some(c => c.length > n / 2);
|
||||
|
||||
return {
|
||||
permutation: perm,
|
||||
cycles,
|
||||
hasLongCycle,
|
||||
id: `perm-${n}-${idx}`,
|
||||
};
|
||||
});
|
||||
}, [n]);
|
||||
|
||||
const filteredPermutations = useMemo(() => {
|
||||
if (filter === 'all') return allPermutations;
|
||||
if (filter === 'long') return allPermutations.filter(p => p.hasLongCycle);
|
||||
return allPermutations.filter(p => !p.hasLongCycle);
|
||||
}, [allPermutations, filter]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const withLong = allPermutations.filter(p => p.hasLongCycle).length;
|
||||
const withoutLong = allPermutations.length - withLong;
|
||||
return { total: allPermutations.length, withLong, withoutLong };
|
||||
}, [allPermutations]);
|
||||
|
||||
const formatCycleNotation = (cycles: Cycle[]) => {
|
||||
return cycles.map(cycle => `(${cycle.elements.join(' ')})`).join(' ');
|
||||
};
|
||||
|
||||
const formatStandardNotation = (perm: number[]) => {
|
||||
return `[${perm.join(', ')}]`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-8 p-6 border-2 border-gray-300 rounded-lg bg-white">
|
||||
<h3 className="text-xl font-semibold mb-4">Permutation Cycle Explorer</h3>
|
||||
|
||||
<div className="mb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-md font-semibold mb-2">
|
||||
Size (n): {n}
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
{[3, 4, 5].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setN(size)}
|
||||
className={`px-4 py-2 rounded font-semibold transition-colors ${
|
||||
n === size
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-md font-semibold mb-2">
|
||||
Filter Permutations:
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 rounded font-semibold transition-colors ${
|
||||
filter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
All ({stats.total})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('long')}
|
||||
className={`px-4 py-2 rounded font-semibold transition-colors ${
|
||||
filter === 'long'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
With Long Cycle ({stats.withLong})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('short')}
|
||||
className={`px-4 py-2 rounded font-semibold transition-colors ${
|
||||
filter === 'short'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Without Long Cycle ({stats.withoutLong})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-blue-50 rounded">
|
||||
<p className="text-sm">
|
||||
<strong>Note:</strong> A "long cycle" is one with length > n/2. For n={n},
|
||||
that means cycles longer than {Math.floor(n / 2)}.
|
||||
There are <strong>{stats.withLong}</strong> permutations with at least one long cycle
|
||||
(about {((stats.withLong / stats.total) * 100).toFixed(1)}%).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{filteredPermutations.map((perm) => (
|
||||
<div
|
||||
key={perm.id}
|
||||
className={`p-3 rounded border-2 ${
|
||||
perm.hasLongCycle
|
||||
? 'bg-red-50 border-red-300'
|
||||
: 'bg-green-50 border-green-300'
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">Standard:</span>{' '}
|
||||
<code className="bg-white px-2 py-1 rounded">
|
||||
{formatStandardNotation(perm.permutation)}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Cycles:</span>{' '}
|
||||
<code className="bg-white px-2 py-1 rounded">
|
||||
{formatCycleNotation(perm.cycles)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
Cycle lengths: {perm.cycles.map(c => c.length).join(', ')}
|
||||
{perm.hasLongCycle && (
|
||||
<span className="ml-2 text-red-700 font-semibold">
|
||||
⚠ Has long cycle!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-100 rounded">
|
||||
<h4 className="font-semibold mb-2">Understanding the Notation:</h4>
|
||||
<ul className="text-sm space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
<strong>Standard notation [a, b, c, ...]:</strong> Position i contains value a[i].
|
||||
For example, [2,3,1] means box 1→2, box 2→3, box 3→1.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cycle notation (a b c ...):</strong> Shows the path a→b→c→...→a.
|
||||
For example, (1 2 3) means 1→2→3→1.
|
||||
</li>
|
||||
<li>
|
||||
Permutations with long cycles (shown in red) would cause the loop strategy to fail
|
||||
for at least one prisoner.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermutationExplorer;
|
||||
222
sites/vibes/src/components/problems/PrisonerGameSimulator.tsx
Normal file
222
sites/vibes/src/components/problems/PrisonerGameSimulator.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface GameState {
|
||||
n: number;
|
||||
boxes: number[];
|
||||
currentPrisoner: number;
|
||||
openedBoxes: number[];
|
||||
foundNumber: boolean;
|
||||
gameOver: boolean;
|
||||
won: boolean;
|
||||
prisonersCompleted: number;
|
||||
attemptsLeft: number;
|
||||
}
|
||||
|
||||
const PrisonerGameSimulator: React.FC = () => {
|
||||
const [n, setN] = useState(10);
|
||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||
|
||||
const initializeGame = (numPrisoners: number) => {
|
||||
// Create a random permutation
|
||||
const boxes = Array.from({ length: numPrisoners }, (_, i) => i + 1);
|
||||
for (let i = boxes.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[boxes[i], boxes[j]] = [boxes[j], boxes[i]];
|
||||
}
|
||||
|
||||
setGameState({
|
||||
n: numPrisoners,
|
||||
boxes,
|
||||
currentPrisoner: 1,
|
||||
openedBoxes: [],
|
||||
foundNumber: false,
|
||||
gameOver: false,
|
||||
won: false,
|
||||
prisonersCompleted: 0,
|
||||
attemptsLeft: Math.floor(numPrisoners / 2),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initializeGame(n);
|
||||
}, []);
|
||||
|
||||
const handleBoxClick = (boxIndex: number) => {
|
||||
if (!gameState || gameState.gameOver || gameState.openedBoxes.includes(boxIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOpenedBoxes = [...gameState.openedBoxes, boxIndex];
|
||||
const numberInBox = gameState.boxes[boxIndex];
|
||||
const foundTarget = numberInBox === gameState.currentPrisoner;
|
||||
const newAttemptsLeft = gameState.attemptsLeft - 1;
|
||||
|
||||
if (foundTarget) {
|
||||
// Prisoner found their number!
|
||||
const newPrisonersCompleted = gameState.prisonersCompleted + 1;
|
||||
|
||||
if (newPrisonersCompleted === gameState.n) {
|
||||
// All prisoners succeeded!
|
||||
setGameState({
|
||||
...gameState,
|
||||
openedBoxes: newOpenedBoxes,
|
||||
foundNumber: true,
|
||||
gameOver: true,
|
||||
won: true,
|
||||
prisonersCompleted: newPrisonersCompleted,
|
||||
attemptsLeft: newAttemptsLeft,
|
||||
});
|
||||
} else {
|
||||
// Move to next prisoner
|
||||
setGameState({
|
||||
...gameState,
|
||||
currentPrisoner: gameState.currentPrisoner + 1,
|
||||
openedBoxes: [],
|
||||
foundNumber: false,
|
||||
prisonersCompleted: newPrisonersCompleted,
|
||||
attemptsLeft: Math.floor(gameState.n / 2),
|
||||
});
|
||||
}
|
||||
} else if (newAttemptsLeft === 0) {
|
||||
// Ran out of attempts - game over
|
||||
setGameState({
|
||||
...gameState,
|
||||
openedBoxes: newOpenedBoxes,
|
||||
foundNumber: false,
|
||||
gameOver: true,
|
||||
won: false,
|
||||
attemptsLeft: 0,
|
||||
});
|
||||
} else {
|
||||
// Continue opening boxes
|
||||
setGameState({
|
||||
...gameState,
|
||||
openedBoxes: newOpenedBoxes,
|
||||
attemptsLeft: newAttemptsLeft,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
initializeGame(n);
|
||||
};
|
||||
|
||||
const handleNChange = (newN: number) => {
|
||||
setN(newN);
|
||||
initializeGame(newN);
|
||||
};
|
||||
|
||||
if (!gameState) return null;
|
||||
|
||||
return (
|
||||
<div className="my-8 p-6 border-2 border-gray-300 rounded-lg bg-gray-50">
|
||||
<div className="mb-6">
|
||||
<label className="block text-lg font-semibold mb-2">
|
||||
Number of Prisoners: {n}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
value={n}
|
||||
onChange={(e) => handleNChange(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer"
|
||||
disabled={gameState.prisonersCompleted > 0 && !gameState.gameOver}
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600 mt-1">
|
||||
<span>5</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-blue-100 rounded">
|
||||
{!gameState.gameOver && (
|
||||
<>
|
||||
<p className="text-lg font-semibold">
|
||||
Prisoner #{gameState.currentPrisoner} is searching for their number
|
||||
</p>
|
||||
<p className="text-md">
|
||||
Attempts remaining: {gameState.attemptsLeft} / {Math.floor(gameState.n / 2)}
|
||||
</p>
|
||||
<p className="text-md">
|
||||
Prisoners completed: {gameState.prisonersCompleted} / {gameState.n}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{gameState.gameOver && gameState.won && (
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
🎉 SUCCESS! All {gameState.n} prisoners found their numbers!
|
||||
</p>
|
||||
)}
|
||||
{gameState.gameOver && !gameState.won && (
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
❌ GAME OVER! Prisoner #{gameState.currentPrisoner} failed to find their number.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-md font-semibold mb-2">Click on boxes to open them:</h3>
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.min(10, Math.ceil(Math.sqrt(gameState.n)))}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{gameState.boxes.map((number, index) => {
|
||||
const isOpened = gameState.openedBoxes.includes(index);
|
||||
const isTarget = number === gameState.currentPrisoner && isOpened;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleBoxClick(index)}
|
||||
disabled={gameState.gameOver || isOpened}
|
||||
className={`
|
||||
aspect-square p-2 rounded border-2 font-semibold text-sm
|
||||
transition-all duration-200
|
||||
${isOpened
|
||||
? isTarget
|
||||
? 'bg-green-400 border-green-600 text-white'
|
||||
: 'bg-red-200 border-red-400 text-gray-700'
|
||||
: 'bg-white border-gray-400 hover:bg-blue-100 hover:border-blue-500 cursor-pointer'
|
||||
}
|
||||
${gameState.gameOver || isOpened ? 'cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
{isOpened ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="text-xs">Box {index + 1}</div>
|
||||
<div className="text-lg">{number}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
{index + 1}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Reset Game
|
||||
</button>
|
||||
|
||||
{gameState.openedBoxes.length > 0 && !gameState.gameOver && (
|
||||
<div className="mt-4 p-3 bg-yellow-100 rounded">
|
||||
<p className="text-sm">
|
||||
<strong>Last opened:</strong> Box {gameState.openedBoxes[gameState.openedBoxes.length - 1] + 1}
|
||||
{' → '}contains number {gameState.boxes[gameState.openedBoxes[gameState.openedBoxes.length - 1]]}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrisonerGameSimulator;
|
||||
225
sites/vibes/src/components/problems/ProbabilityChart.tsx
Normal file
225
sites/vibes/src/components/problems/ProbabilityChart.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import React, { useMemo } from 'react';
|
||||
|
||||
interface ProbabilityChartProps {
|
||||
strategy: 'naive' | 'loop';
|
||||
}
|
||||
|
||||
const ProbabilityChart: React.FC<ProbabilityChartProps> = ({ strategy }) => {
|
||||
const data = useMemo(() => {
|
||||
const points = [];
|
||||
const maxN = 50;
|
||||
|
||||
for (let n = 2; n <= maxN; n += 2) {
|
||||
let probability;
|
||||
|
||||
if (strategy === 'naive') {
|
||||
// Naive strategy: (1/2)^n
|
||||
probability = Math.pow(0.5, n);
|
||||
} else {
|
||||
// Loop strategy: 1 - sum(1/k for k from n/2+1 to n)
|
||||
let sum = 0;
|
||||
for (let k = Math.floor(n / 2) + 1; k <= n; k++) {
|
||||
sum += 1 / k;
|
||||
}
|
||||
probability = 1 - sum;
|
||||
}
|
||||
|
||||
points.push({ n, probability });
|
||||
}
|
||||
|
||||
return points;
|
||||
}, [strategy]);
|
||||
|
||||
// Find min and max for scaling
|
||||
const maxProb = Math.max(...data.map(d => d.probability));
|
||||
const minProb = Math.min(...data.map(d => d.probability));
|
||||
|
||||
// Use log scale for naive strategy since values get extremely small
|
||||
const useLogScale = strategy === 'naive';
|
||||
|
||||
const getY = (prob: number, height: number) => {
|
||||
if (useLogScale) {
|
||||
const logProb = prob > 0 ? Math.log10(prob) : -100;
|
||||
const logMax = Math.log10(maxProb);
|
||||
const logMin = Math.log10(minProb);
|
||||
return height - ((logProb - logMin) / (logMax - logMin)) * height;
|
||||
} else {
|
||||
return height - (prob / maxProb) * height;
|
||||
}
|
||||
};
|
||||
|
||||
const chartWidth = 600;
|
||||
const chartHeight = 300;
|
||||
const padding = { top: 20, right: 20, bottom: 50, left: 70 };
|
||||
const width = chartWidth - padding.left - padding.right;
|
||||
const height = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
// Create path for the line
|
||||
const pathData = data.map((point, i) => {
|
||||
const x = (point.n / 50) * width;
|
||||
const y = getY(point.probability, height);
|
||||
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
||||
}).join(' ');
|
||||
|
||||
// Y-axis labels
|
||||
const yLabels = useLogScale
|
||||
? [-5, -10, -15, -20, -25, -30].map(exp => ({
|
||||
label: `10^${exp}`,
|
||||
value: Math.pow(10, exp),
|
||||
}))
|
||||
: [0, 0.1, 0.2, 0.3, 0.4, 0.5].map(val => ({
|
||||
label: val.toFixed(1),
|
||||
value: val,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="my-8 p-6 border-2 border-gray-300 rounded-lg bg-white">
|
||||
<h3 className="text-xl font-semibold mb-4">
|
||||
{strategy === 'naive' ? 'Naive Strategy: Random Box Selection' : 'Loop Strategy'}
|
||||
</h3>
|
||||
|
||||
<svg width={chartWidth} height={chartHeight} className="mx-auto">
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* Grid lines */}
|
||||
{yLabels.map(({ value }) => {
|
||||
const y = getY(value, height);
|
||||
return (
|
||||
<line
|
||||
key={value}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={width}
|
||||
y2={y}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={height}
|
||||
x2={width}
|
||||
y2={height}
|
||||
stroke="#374151"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* Y-axis */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={0}
|
||||
y2={height}
|
||||
stroke="#374151"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* Plot line */}
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Data points */}
|
||||
{data.filter((_, i) => i % 2 === 0).map((point) => {
|
||||
const x = (point.n / 50) * width;
|
||||
const y = getY(point.probability, height);
|
||||
return (
|
||||
<circle
|
||||
key={point.n}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{[10, 20, 30, 40, 50].map((n) => {
|
||||
const x = (n / 50) * width;
|
||||
return (
|
||||
<text
|
||||
key={n}
|
||||
x={x}
|
||||
y={height + 25}
|
||||
textAnchor="middle"
|
||||
fontSize="14"
|
||||
fill="#374151"
|
||||
>
|
||||
{n}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yLabels.map(({ label, value }) => {
|
||||
const y = getY(value, height);
|
||||
return (
|
||||
<text
|
||||
key={value}
|
||||
x={-10}
|
||||
y={y + 5}
|
||||
textAnchor="end"
|
||||
fontSize="12"
|
||||
fill="#374151"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis title */}
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height + 45}
|
||||
textAnchor="middle"
|
||||
fontSize="16"
|
||||
fontWeight="600"
|
||||
fill="#374151"
|
||||
>
|
||||
Number of Prisoners (n)
|
||||
</text>
|
||||
|
||||
{/* Y-axis title */}
|
||||
<text
|
||||
x={-height / 2}
|
||||
y={-50}
|
||||
textAnchor="middle"
|
||||
fontSize="16"
|
||||
fontWeight="600"
|
||||
fill="#374151"
|
||||
transform={`rotate(-90, ${-height / 2}, -50)`}
|
||||
>
|
||||
Probability of Success
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="mt-4 p-4 bg-gray-100 rounded">
|
||||
<p className="text-sm">
|
||||
{strategy === 'naive' ? (
|
||||
<>
|
||||
The naive strategy has probability <strong>(1/2)^n</strong> which decreases
|
||||
exponentially. Note the logarithmic scale on the y-axis!
|
||||
With 20 prisoners, the probability is already less than one in a million.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The loop strategy maintains a probability around <strong>31%</strong> regardless
|
||||
of the number of prisoners, approaching 1 - ln(2) ≈ 0.3069 as n → ∞.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProbabilityChart;
|
||||
116
sites/vibes/src/components/problems/README.md
Normal file
116
sites/vibes/src/components/problems/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# 100 Prisoners Problem - Interactive Blog Post
|
||||
|
||||
This directory contains the interactive React components for the "100 Prisoners Problem" blog post, inspired by the interactive style of Nicky Case's explorable explanations.
|
||||
|
||||
## Files Created
|
||||
|
||||
### Blog Post
|
||||
|
||||
- **`src/content/puzzles/100-prisoners.mdx`** - The main blog post with embedded interactive components
|
||||
|
||||
### Interactive Components
|
||||
|
||||
1. **`PrisonerGameSimulator.tsx`** - Interactive game simulation
|
||||
- Allows users to play the prisoner game themselves
|
||||
- Adjustable number of prisoners (5-100)
|
||||
- Manual box clicking to experience the problem
|
||||
- Visual feedback for success/failure
|
||||
- Tracks current prisoner and attempts remaining
|
||||
|
||||
2. **`ProbabilityChart.tsx`** - Probability visualization
|
||||
- Shows probability curves for naive and loop strategies
|
||||
- Uses logarithmic scale for naive strategy (values get extremely small)
|
||||
- Interactive SVG chart with proper axes and labels
|
||||
- Includes explanatory text
|
||||
|
||||
3. **`PermutationExplorer.tsx`** - Cycle structure explorer
|
||||
- Generates all permutations for n ∈ {3, 4, 5}
|
||||
- Displays both standard and cycle notation
|
||||
- Filter by presence of long cycles (> n/2)
|
||||
- Color-coded display (red for long cycles, green for short)
|
||||
- Shows statistics about cycle length distribution
|
||||
|
||||
4. **`StrategyComparison.tsx`** - Side-by-side strategy comparison
|
||||
- Compares naive vs. loop strategy on same chart
|
||||
- Shows probabilities up to n=100
|
||||
- Includes detailed numerical breakdown
|
||||
- Highlights the key 31.18% result at n=100
|
||||
|
||||
## Features
|
||||
|
||||
All components are:
|
||||
|
||||
- ✅ Fully interactive with React hooks
|
||||
- ✅ Styled with Tailwind CSS classes
|
||||
- ✅ Responsive for mobile and desktop
|
||||
- ✅ Self-contained with no external dependencies beyond React
|
||||
- ✅ Client-side only (using `client:only="react"` in Astro)
|
||||
|
||||
## How It Works
|
||||
|
||||
### The Problem
|
||||
|
||||
100 prisoners must each find their own number among 100 boxes by opening at most 50 boxes. If all prisoners succeed, they go free. They can agree on a strategy beforehand but cannot communicate during the challenge.
|
||||
|
||||
### The Naive Strategy
|
||||
|
||||
Each prisoner opens 50 random boxes. Probability of success: (1/2)^100 ≈ 0%
|
||||
|
||||
### The Clever Strategy (Loop Following)
|
||||
|
||||
1. Open the box with your own number
|
||||
2. Look at the number inside
|
||||
3. Open the box with that number
|
||||
4. Repeat until you find your number
|
||||
|
||||
This exploits the cycle structure of permutations. Success probability: ~31.18%!
|
||||
|
||||
### Key Mathematical Insight
|
||||
|
||||
The prisoners succeed if and only if the permutation of numbers in boxes has no cycle longer than n/2. For n=100, the probability of this is:
|
||||
|
||||
P(success) = 1 - Σ(1/k) for k=51 to 100 ≈ 0.3118
|
||||
|
||||
As n→∞, this converges to 1 - ln(2) ≈ 0.3069.
|
||||
|
||||
## Usage in MDX
|
||||
|
||||
The components are imported and used like this:
|
||||
|
||||
\`\`\`mdx
|
||||
import PrisonerGameSimulator from '@/components/problems/PrisonerGameSimulator.tsx';
|
||||
import ProbabilityChart from '@/components/problems/ProbabilityChart.tsx';
|
||||
import PermutationExplorer from '@/components/problems/PermutationExplorer.tsx';
|
||||
import StrategyComparison from '@/components/problems/StrategyComparison.tsx';
|
||||
|
||||
<PrisonerGameSimulator client:only="react" />
|
||||
<ProbabilityChart strategy="naive" client:only="react" />
|
||||
<PermutationExplorer client:only="react" />
|
||||
<StrategyComparison client:only="react" />
|
||||
\`\`\`
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Inspired by Nicky Case's "Parable of the Polygons", these components aim to:
|
||||
|
||||
- Make abstract mathematical concepts tangible through interaction
|
||||
- Allow readers to explore and discover patterns themselves
|
||||
- Provide immediate visual feedback
|
||||
- Balance playfulness with mathematical rigor
|
||||
- Guide understanding through progressive disclosure
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential additions:
|
||||
|
||||
- Auto-play mode for the game simulator using the loop strategy
|
||||
- Animation showing cycle formation in real-time
|
||||
- Monte Carlo simulation running multiple trials
|
||||
- Variant problems (different box-opening rules, malicious director, etc.)
|
||||
- 3D visualization of cycle structures for larger n
|
||||
|
||||
## References
|
||||
|
||||
- [Wikipedia: 100 Prisoners Problem](https://en.wikipedia.org/wiki/100_prisoners_problem)
|
||||
- [Nicky Case's Explorable Explanations](https://ncase.me/)
|
||||
- Original problem: Gál & Miltersen (2003)
|
||||
313
sites/vibes/src/components/problems/StrategyComparison.tsx
Normal file
313
sites/vibes/src/components/problems/StrategyComparison.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import React, { useMemo } from 'react';
|
||||
|
||||
const StrategyComparison: React.FC = () => {
|
||||
const data = useMemo(() => {
|
||||
const points = [];
|
||||
const maxN = 100;
|
||||
|
||||
for (let n = 5; n <= maxN; n += 5) {
|
||||
// Naive strategy
|
||||
const naiveProb = Math.pow(0.5, n);
|
||||
|
||||
// Loop strategy: 1 - sum(1/k for k from n/2+1 to n)
|
||||
let sum = 0;
|
||||
for (let k = Math.floor(n / 2) + 1; k <= n; k++) {
|
||||
sum += 1 / k;
|
||||
}
|
||||
const loopProb = 1 - sum;
|
||||
|
||||
points.push({ n, naiveProb, loopProb });
|
||||
}
|
||||
|
||||
return points;
|
||||
}, []);
|
||||
|
||||
const chartWidth = 700;
|
||||
const chartHeight = 350;
|
||||
const padding = { top: 20, right: 100, bottom: 50, left: 70 };
|
||||
const width = chartWidth - padding.left - padding.right;
|
||||
const height = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
// Only show loop strategy on linear scale (naive is too small to see)
|
||||
const maxProb = 0.5;
|
||||
|
||||
const getY = (prob: number) => {
|
||||
return height - (prob / maxProb) * height;
|
||||
};
|
||||
|
||||
const getX = (n: number) => {
|
||||
return (n / 100) * width;
|
||||
};
|
||||
|
||||
// Create paths for both strategies
|
||||
const loopPath = data.map((point, i) => {
|
||||
const x = getX(point.n);
|
||||
const y = getY(point.loopProb);
|
||||
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
||||
}).join(' ');
|
||||
|
||||
// For naive, we'll just show it near zero
|
||||
const naivePath = data.map((point, i) => {
|
||||
const x = getX(point.n);
|
||||
const y = height - 2; // Near the bottom (representing ~0)
|
||||
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<div className="my-8 p-6 border-2 border-gray-300 rounded-lg bg-white">
|
||||
<h3 className="text-xl font-semibold mb-4">Strategy Comparison</h3>
|
||||
|
||||
<svg width={chartWidth} height={chartHeight} className="mx-auto">
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* Grid lines */}
|
||||
{[0, 0.1, 0.2, 0.3, 0.4, 0.5].map((val) => {
|
||||
const y = getY(val);
|
||||
return (
|
||||
<g key={val}>
|
||||
<line
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={width}
|
||||
y2={y}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={-10}
|
||||
y={y + 5}
|
||||
textAnchor="end"
|
||||
fontSize="12"
|
||||
fill="#374151"
|
||||
>
|
||||
{val.toFixed(1)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={height}
|
||||
x2={width}
|
||||
y2={height}
|
||||
stroke="#374151"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* Y-axis */}
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={0}
|
||||
y2={height}
|
||||
stroke="#374151"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* Loop strategy line */}
|
||||
<path
|
||||
d={loopPath}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Naive strategy line (at the bottom) */}
|
||||
<path
|
||||
d={naivePath}
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
|
||||
{/* Data points for loop strategy */}
|
||||
{data.filter((_, i) => i % 2 === 0).map((point) => {
|
||||
const x = getX(point.n);
|
||||
const y = getY(point.loopProb);
|
||||
return (
|
||||
<circle
|
||||
key={point.n}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
fill="#10b981"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{[20, 40, 60, 80, 100].map((n) => {
|
||||
const x = getX(n);
|
||||
return (
|
||||
<text
|
||||
key={n}
|
||||
x={x}
|
||||
y={height + 25}
|
||||
textAnchor="middle"
|
||||
fontSize="14"
|
||||
fill="#374151"
|
||||
>
|
||||
{n}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* X-axis title */}
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height + 45}
|
||||
textAnchor="middle"
|
||||
fontSize="16"
|
||||
fontWeight="600"
|
||||
fill="#374151"
|
||||
>
|
||||
Number of Prisoners (n)
|
||||
</text>
|
||||
|
||||
{/* Y-axis title */}
|
||||
<text
|
||||
x={-height / 2}
|
||||
y={-50}
|
||||
textAnchor="middle"
|
||||
fontSize="16"
|
||||
fontWeight="600"
|
||||
fill="#374151"
|
||||
transform={`rotate(-90, ${-height / 2}, -50)`}
|
||||
>
|
||||
Probability of Success
|
||||
</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${width + 20}, 20)`}>
|
||||
<rect x={0} y={0} width={15} height={15} fill="#10b981" />
|
||||
<text x={20} y={12} fontSize="14" fill="#374151">
|
||||
Loop Strategy
|
||||
</text>
|
||||
|
||||
<line
|
||||
x1={0}
|
||||
y1={35}
|
||||
x2={15}
|
||||
y2={35}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={3}
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x={20} y={40} fontSize="14" fill="#374151">
|
||||
Naive Strategy
|
||||
</text>
|
||||
<text x={20} y={55} fontSize="11" fill="#6b7280">
|
||||
(≈ 0 for all n)
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Highlight key value */}
|
||||
<g>
|
||||
{(() => {
|
||||
const n100Point = data.find(p => p.n === 100);
|
||||
if (n100Point) {
|
||||
const x = getX(100);
|
||||
const y = getY(n100Point.loopProb);
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<line
|
||||
x1={x}
|
||||
y1={y - 10}
|
||||
x2={x}
|
||||
y2={y - 40}
|
||||
stroke="#374151"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={y - 45}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="600"
|
||||
fill="#10b981"
|
||||
>
|
||||
31.18%
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-red-50 border-2 border-red-300 rounded">
|
||||
<h4 className="font-semibold text-red-900 mb-2">❌ Naive Strategy</h4>
|
||||
<p className="text-sm mb-2">
|
||||
Each prisoner randomly selects 50 boxes.
|
||||
</p>
|
||||
<div className="text-xs space-y-1">
|
||||
<p><strong>n=10:</strong> P ≈ {(Math.pow(0.5, 10) * 100).toExponential(2)}%</p>
|
||||
<p><strong>n=20:</strong> P ≈ {(Math.pow(0.5, 20) * 100).toExponential(2)}%</p>
|
||||
<p><strong>n=50:</strong> P ≈ {(Math.pow(0.5, 50) * 100).toExponential(2)}%</p>
|
||||
<p><strong>n=100:</strong> P ≈ {(Math.pow(0.5, 100) * 100).toExponential(2)}%</p>
|
||||
</div>
|
||||
<p className="text-xs mt-2 italic">
|
||||
Essentially impossible for any reasonable n!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 border-2 border-green-300 rounded">
|
||||
<h4 className="font-semibold text-green-900 mb-2">✅ Loop Strategy</h4>
|
||||
<p className="text-sm mb-2">
|
||||
Each prisoner follows the cycle starting at their number.
|
||||
</p>
|
||||
<div className="text-xs space-y-1">
|
||||
{(() => {
|
||||
const calcProb = (n: number) => {
|
||||
let sum = 0;
|
||||
for (let k = Math.floor(n / 2) + 1; k <= n; k++) {
|
||||
sum += 1 / k;
|
||||
}
|
||||
return ((1 - sum) * 100).toFixed(2);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<p><strong>n=10:</strong> P ≈ {calcProb(10)}%</p>
|
||||
<p><strong>n=20:</strong> P ≈ {calcProb(20)}%</p>
|
||||
<p><strong>n=50:</strong> P ≈ {calcProb(50)}%</p>
|
||||
<p><strong>n=100:</strong> P ≈ {calcProb(100)}%</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs mt-2 italic">
|
||||
Converges to 1 - ln(2) ≈ 30.69% as n → ∞
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded">
|
||||
<p className="text-sm">
|
||||
<strong>Key Insight:</strong> The loop strategy transforms an impossible problem
|
||||
(probability ≈ 10<sup>-30</sup>) into a reasonable one (probability ≈ 31%).
|
||||
This astronomical improvement comes from exploiting the mathematical structure
|
||||
of permutations rather than relying on independent random trials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyComparison;
|
||||
190
sites/vibes/src/components/sections/about-section.tsx
Normal file
190
sites/vibes/src/components/sections/about-section.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { PlusSigns } from "@/components/icons/plus-signs";
|
||||
import JoinUs from "@/components/sections/join-us";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
|
||||
export function AboutSection() {
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<section className="lg:py-15 container relative max-w-5xl py-10 md:py-12">
|
||||
<div className="">
|
||||
<h1 className="text-4xl font-semibold tracking-tighter md:text-5xl lg:text-6xl">
|
||||
A different
|
||||
<br />
|
||||
kind of bank.
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-mona mt-4 max-w-xl text-2xl md:text-3xl">
|
||||
We're on a mission to transform financial services by
|
||||
harnessing vast amounts of untapped financial data.
|
||||
</p>
|
||||
</div>
|
||||
{/* Background decoration */}
|
||||
<>
|
||||
<div className="absolute inset-0 z-[-1] -translate-y-1/2 translate-x-1/4 blur-[100px] will-change-transform">
|
||||
<div className="bg-primary-gradient/25 -translate-x-1/5 absolute right-0 top-0 h-[250px] w-[800px] rounded-full md:h-[300px]" />
|
||||
<div className="bg-secondary-gradient/15 absolute right-0 top-0 size-[250px] -translate-x-1/3 -translate-y-1/3 rounded-full md:size-[400px]" />
|
||||
</div>
|
||||
<div className="absolute -inset-40 z-[-1] [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_80%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="container max-w-5xl border-y py-5">
|
||||
<h2 className="mini-title">By the numbers</h2>
|
||||
<div className="mt-6 grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<h3 className="text-4xl font-medium tracking-tight md:text-5xl lg:text-6xl">
|
||||
$150M
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 font-medium">Raised</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-4xl font-medium tracking-tight md:text-5xl lg:text-6xl">
|
||||
20K
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 font-medium">Companies</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-4xl font-medium tracking-tight md:text-5xl lg:text-6xl">
|
||||
1.3B
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 font-medium">
|
||||
Monthly transactions
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-4xl font-medium tracking-tight md:text-5xl lg:text-6xl">
|
||||
1.5K
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 font-medium">
|
||||
Connections per minute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<section className="lg:py-15 container max-w-5xl py-10 md:py-12">
|
||||
<div className="max-w-xl space-y-5 md:space-y-8 lg:space-y-10">
|
||||
<p className="text-lg">
|
||||
Financial services have changed, are changing, and will continue to
|
||||
change — for the better. Now is the time for finance to be
|
||||
developer-first and API-driven. But in order to do this — it needs a
|
||||
new foundation.
|
||||
</p>
|
||||
|
||||
<h2 className="font-mona text-2xl font-medium tracking-tight md:text-3xl">
|
||||
We were always told that banks can't be platforms.
|
||||
</h2>
|
||||
<p className="text-lg">
|
||||
Everyone tried fixing the problem by layering APIs on legacy
|
||||
systems, creating abstractions and inefficiencies. We have spent
|
||||
years building and scaling companies like Plaid, Stripe, and Affirm,
|
||||
confronting these limitations firsthand. The current solutions
|
||||
aren't good enough. We believe that banking infrastructure must
|
||||
be reimagined as an API platform. But we had to start from ground
|
||||
zero.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Image Grid Section */}
|
||||
<section className="lg:pb-15 my-5 pb-10 md:my-8 md:pb-12 lg:my-12">
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
}}
|
||||
>
|
||||
<CarouselContent className="-ml-4">
|
||||
<CarouselItem className="basis-[80%] lg:basis-1/3 xl:basis-[40%]">
|
||||
<div className="relative h-[330px] lg:h-[440px]">
|
||||
<img
|
||||
src="/images/about/4.webp"
|
||||
alt="Charter team member working"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem className="basis-[80%] lg:basis-1/3 xl:basis-[40%]">
|
||||
<div className="relative h-[330px] lg:h-[440px]">
|
||||
<img
|
||||
src="/images/about/2.webp"
|
||||
alt="Modern workspace setup"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
<CarouselItem className="basis-[80%] lg:basis-1/3 xl:basis-[40%]">
|
||||
<div className="relative h-[330px] lg:h-[440px]">
|
||||
<img
|
||||
src="/images/about/3.webp"
|
||||
alt="Team collaboration"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</section>
|
||||
|
||||
{/* CoreAPI Section */}
|
||||
<section className="container max-w-5xl pb-10 md:pb-20">
|
||||
<div className="ml-auto mr-0 max-w-xl space-y-5 md:space-y-8 lg:-translate-x-10 lg:space-y-10">
|
||||
<p className="text-lg">
|
||||
We started building CoreAPI in 2019 and launched in 2022. Every
|
||||
endpoint has been designed from the ground up — with no technical
|
||||
debt or legacy systems. We are purpose-built to power financial
|
||||
innovation for the next hundred years.
|
||||
</p>
|
||||
|
||||
<h2 className="font-mona text-2xl font-medium tracking-tight md:text-3xl">
|
||||
We are a bit of a unique company — not your standard tech or fintech
|
||||
company.
|
||||
</h2>
|
||||
|
||||
<p className="text-lg">
|
||||
We are 100% founder and team-owned, profitable, and we keep our team
|
||||
lean. Over time, this page will become more polished, but for now,
|
||||
we're focused on delivering for developers.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Founding Team Section */}
|
||||
<section className="lg:py-15 container max-w-5xl py-10 md:py-12">
|
||||
<div className="grid gap-5 md:grid-cols-2 md:gap-10 lg:gap-16">
|
||||
<div className="order-2 md:order-1">
|
||||
<h2 className="font-mona text-3xl font-semibold md:text-4xl">
|
||||
The founding team
|
||||
</h2>
|
||||
<p className="mt-5 text-lg md:mt-6">
|
||||
We started building CoreAPI in 2019 and launched in 2022. Every
|
||||
endpoint has been designed from the ground up — with no technical
|
||||
debt or legacy systems. We are purpose-built to power financial
|
||||
innovation for the next hundred years. We are 100% founder and
|
||||
team-owned, profitable, and we keep our team lean. Over time, this
|
||||
page will become more polished, but for now, we're focused on
|
||||
delivering for developers. If you're interested in building
|
||||
the future of financial APIs, check out our open roles below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative order-1 h-[300px] w-full md:order-2 md:h-[400px]">
|
||||
<img
|
||||
src="/images/about/1.webp"
|
||||
alt="Founding team collaboration"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<JoinUs />
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
sites/vibes/src/components/sections/ai-chatbot.tsx
Normal file
80
sites/vibes/src/components/sections/ai-chatbot.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { ChevronRight, Timer, Wallet, Terminal, Calendar } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const features = [
|
||||
{
|
||||
description: "Faster than Wire, and more affordable, too.",
|
||||
icon: Timer,
|
||||
href: "/pricing",
|
||||
},
|
||||
{
|
||||
description: "Send or request funds. FedNow does both.",
|
||||
icon: Wallet,
|
||||
href: "/pricing",
|
||||
},
|
||||
{
|
||||
description: "API request to creation in seven minutes.",
|
||||
icon: Terminal,
|
||||
href: "/pricing",
|
||||
},
|
||||
{
|
||||
description: "Settle any time, any day. What Bank holiday?",
|
||||
icon: Calendar,
|
||||
href: "/pricing",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AIChatbot() {
|
||||
return (
|
||||
<section id="ai-chatbot" className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="container max-w-5xl">
|
||||
<div className="text-center">
|
||||
<h3 className="mini-title">MORE COMPUTER FUGAZI</h3>
|
||||
|
||||
<h2 className="mt-4 text-4xl font-semibold tracking-tight md:text-5xl lg:text-6xl">
|
||||
And we have an AI chatbot
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-3 text-xl font-medium md:text-2xl">
|
||||
{`We're betting on agents replacing our staff next year.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-2.5 md:mt-12 lg:mt-20 lg:grid-cols-4 lg:gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<Card key={index} className="flex flex-col">
|
||||
<CardHeader className="max-md:p-3">
|
||||
<feature.icon className="text-primary size-8" />
|
||||
<CardDescription className="text-foreground mt-4 font-medium">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto max-md:p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-border group w-[min(100%,300px)]"
|
||||
>
|
||||
<a href={feature.href}>
|
||||
Learn more
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
about {feature.description.toLowerCase()}
|
||||
</span>
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
57
sites/vibes/src/components/sections/all-blogs.tsx
Normal file
57
sites/vibes/src/components/sections/all-blogs.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { BLOG_SITES } from "@/blog-sites.js";
|
||||
|
||||
const features = BLOG_SITES.map((site) => ({
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
href: site.url,
|
||||
}));
|
||||
|
||||
export default function AllBlogs() {
|
||||
return (
|
||||
<section id="ai-chatbot" className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="container max-w-5xl">
|
||||
<div className="text-center">
|
||||
<h3 className="mini-title">CURRENTLY ACTIVE BLOGS</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:mt-12 lg:mt-20 lg:grid-cols-3 lg:gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<Card key={index} className="flex flex-col">
|
||||
<CardHeader className="max-md:p-3">
|
||||
<h3 className="text-lg font-semibold">{feature.title}</h3>
|
||||
<CardDescription className="text-foreground mt-4 font-medium">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto max-md:p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
className="border-border group w-[min(100%,300px)]"
|
||||
>
|
||||
<a href={feature.href}>
|
||||
Read On
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
about {feature.description.toLowerCase()}
|
||||
</span>
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
88
sites/vibes/src/components/sections/blog-post.tsx
Normal file
88
sites/vibes/src/components/sections/blog-post.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar, Clock, ArrowLeft, User } from "lucide-react";
|
||||
|
||||
const BlogPost = ({
|
||||
post,
|
||||
children,
|
||||
}: {
|
||||
post: any;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { title, authorName, image, pubDate, description, authorImage } =
|
||||
post.data;
|
||||
return (
|
||||
<>
|
||||
{/* Hero section with gradient background and post info */}
|
||||
<section className="pb-8 pt-4">
|
||||
<div className="container max-w-4xl">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-4xl lg:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-md flex-wrap items-center justify-center gap-6">
|
||||
{/* Author info */}
|
||||
{authorImage ? (
|
||||
<div className="flex items-center gap-3">
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full shadow-sm">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium">
|
||||
{authorName || "Anonymous"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Author</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-sm">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{format(pubDate, "MMMM d, yyyy")}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-sm">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>5 min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured image */}
|
||||
{image && (
|
||||
<section className="container max-w-5xl py-4">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-xl border">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Article content */}
|
||||
<section className="container my-10 max-w-3xl">
|
||||
<article className="prose prose-lg dark:prose-invert prose-headings:font-semibold prose-a:text-primary mx-auto">
|
||||
{children}
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { BlogPost };
|
||||
135
sites/vibes/src/components/sections/blog-posts.tsx
Normal file
135
sites/vibes/src/components/sections/blog-posts.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Calendar, Clock, User } from "lucide-react";
|
||||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
const BlogPosts = ({
|
||||
posts,
|
||||
collection = "",
|
||||
}: {
|
||||
posts: any[];
|
||||
collection?: string;
|
||||
}) => {
|
||||
// Get the first post as the featured post
|
||||
const featuredPost = posts[0];
|
||||
const remainingPosts = posts.slice(1);
|
||||
const hrefFor = (post: any) =>
|
||||
collection ? `/${collection}/${post.id}/` : `/${post.id}/`;
|
||||
|
||||
return (
|
||||
<div className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="absolute -inset-40 z-[-1] [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_75%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Featured Post */}
|
||||
<section className="mt-8">
|
||||
<div className="container max-w-6xl">
|
||||
<a
|
||||
href={hrefFor(featuredPost)}
|
||||
className="bg-card group relative mb-16 overflow-hidden rounded-xl border shadow-md"
|
||||
>
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<div className="lg:w-1/2">
|
||||
<div className="p-2 lg:p-4">
|
||||
<img
|
||||
src={featuredPost.data.image}
|
||||
alt={featuredPost.data.title}
|
||||
className="aspect-video w-full rounded-lg object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-4 pb-8 lg:w-1/2 lg:pr-8">
|
||||
<Badge variant="outline" className="mb-3 w-fit">
|
||||
Featured Post
|
||||
</Badge>
|
||||
<h2 className="mb-3 text-2xl font-bold group-hover:underline md:text-3xl">
|
||||
{featuredPost.data.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3 text-base">
|
||||
{featuredPost.data.description}
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex gap-4">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-4 w-4" />
|
||||
{new Date(featuredPost.data.pubDate).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Clock className="mr-1 h-4 w-4" />5 min read
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Regular Posts Grid */}
|
||||
<section className="mt-8">
|
||||
<div className="container max-w-6xl">
|
||||
<h2 className="mb-8 text-xl font-semibold md:text-2xl">
|
||||
Recent Articles
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{remainingPosts.map((post) => (
|
||||
<a
|
||||
key={post.id}
|
||||
className="bg-card group rounded-xl border shadow-sm transition-all hover:shadow-md"
|
||||
href={hrefFor(post)}
|
||||
>
|
||||
<div className="p-2">
|
||||
<img
|
||||
src={post.data.image}
|
||||
alt={post.data.title}
|
||||
className="aspect-video w-full rounded-lg object-cover transition-transform group-hover:scale-[1.01]"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-5 pt-2">
|
||||
<h2 className="mb-2 text-xl font-semibold group-hover:underline">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||
{post.data.description}
|
||||
</p>
|
||||
|
||||
<div className="text-muted-foreground mt-3 flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{new Date(post.data.pubDate).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>5 min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BlogPosts };
|
||||
100
sites/vibes/src/components/sections/code-security.tsx
Normal file
100
sites/vibes/src/components/sections/code-security.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
id: "move-money",
|
||||
title: "Move and hold money",
|
||||
description:
|
||||
"Previously, emerging financial companies were forced to navigate costly middleware solutions that connected them to outdated sponsor bank systems.",
|
||||
image: "/images/homepage/code-snippet.webp",
|
||||
},
|
||||
{
|
||||
id: "card-program",
|
||||
title: "Build a modern card program",
|
||||
description:
|
||||
"Create and manage virtual and physical cards with complete control over spending limits, merchant categories, and real-time transaction monitoring.",
|
||||
image: "/images/homepage/code-snippet.webp",
|
||||
},
|
||||
{
|
||||
id: "lend-money",
|
||||
title: "Lend money",
|
||||
description:
|
||||
"Offer various lending products through our platform with automated underwriting, loan servicing, and compliance management.",
|
||||
image: "/images/homepage/code-snippet.webp",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CodeSecurity() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="code-security"
|
||||
className="container max-w-5xl py-16 md:py-28 lg:py-32"
|
||||
>
|
||||
<div className="from-primary-gradient/20 bg-linear-to-bl relative overflow-hidden rounded-3xl border to-transparent py-5 md:py-6 lg:py-8">
|
||||
<div className="absolute inset-0 z-[-1]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
<div className="md:px-6 lg:px-8">
|
||||
<div className="max-md:px-5">
|
||||
<h3 className="mini-title">WHY CHARTER?</h3>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-tight md:text-4xl lg:text-5xl">
|
||||
Code security
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-10 flex gap-12 overflow-hidden max-md:flex-col md:mt-16 lg:mt-20">
|
||||
<Accordion
|
||||
type="single"
|
||||
className="flex-1"
|
||||
defaultValue="0"
|
||||
onValueChange={(value) => setSelectedIndex(Number(value))}
|
||||
>
|
||||
{FEATURES.map((feature, index) => (
|
||||
<AccordionItem
|
||||
key={feature.id}
|
||||
value={index.toString()}
|
||||
className="border-black/20 last:border-none dark:border-white/20"
|
||||
>
|
||||
<AccordionTrigger className="hover:no-underline max-md:px-5">
|
||||
<h3 className="text-input font-inter text-xl font-bold">
|
||||
{feature.title}
|
||||
</h3>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="">
|
||||
<p className="text-muted-foreground font-medium leading-relaxed max-md:px-5">
|
||||
{feature.description}
|
||||
</p>
|
||||
<div className="relative mt-4 h-[280px] translate-x-5 md:hidden">
|
||||
<img
|
||||
src={feature.image}
|
||||
alt={feature.title}
|
||||
className="size-full rounded-2xl object-cover object-left-top shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
<div className="relative h-[240px] max-md:hidden max-md:translate-x-6 md:flex-1">
|
||||
<img
|
||||
src={FEATURES[selectedIndex].image}
|
||||
alt={FEATURES[selectedIndex].title}
|
||||
className="size-full rounded-2xl object-cover object-left-top shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
sites/vibes/src/components/sections/contact-page.tsx
Normal file
84
sites/vibes/src/components/sections/contact-page.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { PlusSigns } from "@/components/icons/plus-signs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function ContactPage() {
|
||||
return (
|
||||
<section className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="container max-w-2xl">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight md:text-5xl lg:text-6xl">
|
||||
Book a demo
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-4 text-2xl md:text-3xl">
|
||||
Learn how Charter can work for you
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-5 md:mt-12 lg:mt-20">
|
||||
{/* First Name */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="firstName">First name</Label>
|
||||
<Input id="firstName" placeholder="Enter your first name" />
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="lastName">Last name</Label>
|
||||
<Input id="lastName" placeholder="Enter your last name" />
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="company">
|
||||
Company name{" "}
|
||||
<span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input id="company" placeholder="Enter your company name" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="employees">Number of employees</Label>
|
||||
<Input
|
||||
id="employees"
|
||||
placeholder="Enter number of employees"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
rows={5}
|
||||
placeholder="Enter your message"
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="lg">
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="absolute -inset-40 z-[-1] [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_75%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
120
sites/vibes/src/components/sections/faq-page.tsx
Normal file
120
sites/vibes/src/components/sections/faq-page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { PlusSigns } from "@/components/icons/plus-signs";
|
||||
import Testimonials from "@/components/sections/testimonials";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
category: "SUPPORT",
|
||||
items: [
|
||||
{
|
||||
question: "Is there a free version?",
|
||||
answer:
|
||||
"Yes! We offer a generous free plan with just enough features except that one feature you really want! Our strategy is to get your credit card details on file then steadily double our prices against inflation rates.",
|
||||
},
|
||||
{
|
||||
question: "How do I update my account without breaking my laptop?",
|
||||
answer:
|
||||
"Our platform is designed with safety in mind. You can update your account settings through our intuitive dashboard without any risk to your hardware. We have multiple safeguards in place to prevent any system conflicts.",
|
||||
},
|
||||
{
|
||||
question: "Is support free, or do I need to Google everything?",
|
||||
answer:
|
||||
"We provide comprehensive support at no additional cost. Our dedicated support team is available 24/7 to help you with any questions or issues you might encounter. No need to rely on Google - we're here to help!",
|
||||
},
|
||||
{
|
||||
question: "Are you going to be subsumed by AI?",
|
||||
answer:
|
||||
"While we embrace AI technology to enhance our services, we maintain a strong human element in our operations. Our team works alongside AI to provide the best possible service while ensuring human oversight and decision-making remain central to our operations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "YOUR ACCOUNT",
|
||||
items: [
|
||||
{
|
||||
question: "Is support free, or do I need to Google everything?",
|
||||
answer:
|
||||
"We provide comprehensive support at no additional cost. Our dedicated support team is available 24/7 to help you with any questions or issues you might encounter. No need to rely on Google - we're here to help!",
|
||||
},
|
||||
{
|
||||
question: "Are you going to be subsumed by AI?",
|
||||
answer:
|
||||
"While we embrace AI technology to enhance our services, we maintain a strong human element in our operations. Our team works alongside AI to provide the best possible service while ensuring human oversight and decision-making remain central to our operations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "OTHER QUESTIONS",
|
||||
items: [
|
||||
{
|
||||
question: "Is support free, or do I need to Google everything?",
|
||||
answer:
|
||||
"We provide comprehensive support at no additional cost. Our dedicated support team is available 24/7 to help you with any questions or issues you might encounter. No need to rely on Google - we're here to help!",
|
||||
},
|
||||
{
|
||||
question: "Are you going to be subsumed by AI?",
|
||||
answer:
|
||||
"While we embrace AI technology to enhance our services, we maintain a strong human element in our operations. Our team works alongside AI to provide the best possible service while ensuring human oversight and decision-making remain central to our operations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<>
|
||||
<section className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight md:text-5xl lg:text-6xl">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-4 text-2xl md:text-3xl">
|
||||
Everything you need to know about Charter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 max-w-2xl space-y-12 md:mt-12 lg:mt-20">
|
||||
{FAQ_ITEMS.map((category) => (
|
||||
<Card key={category.category} className="border-hidden">
|
||||
<CardHeader className="pb-0">
|
||||
<h3 className="text-accent-foreground border-b pb-4 font-mono text-sm font-medium uppercase tracking-widest">
|
||||
{category.category}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{category.items.map((item, i) => (
|
||||
<AccordionItem
|
||||
key={i}
|
||||
value={`${category.category}-${i}`}
|
||||
className="border-muted border-b last:border-0"
|
||||
>
|
||||
<AccordionTrigger className="text-base font-medium hover:no-underline">
|
||||
{item.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-base font-medium">
|
||||
{item.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -inset-40 z-[-1] [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_75%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
</section>
|
||||
<Testimonials />
|
||||
</>
|
||||
);
|
||||
}
|
||||
157
sites/vibes/src/components/sections/featured-posts.tsx
Normal file
157
sites/vibes/src/components/sections/featured-posts.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
Unparalleled
|
||||
<br />
|
||||
VAR flexibility
|
||||
</>
|
||||
),
|
||||
description: [
|
||||
"VAR is often known for its lengthy processing times. At Charter, we focus on achieving the fastest VAR transfers—often completed within hours.",
|
||||
"Unlike traditional banks and middleware, we connect directly with the Federal Reserve to ensure the fastest, most transparent transfers possible.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/why-charter/1.webp",
|
||||
alt: "Code snippet",
|
||||
type: "fill",
|
||||
},
|
||||
className:
|
||||
"flex flex-col pl-6 py-6 overflow-hidden md:col-span-3 md:flex-row gap-6 md:gap-12",
|
||||
headerClassName: "flex-2 p-0",
|
||||
contentClassName:
|
||||
"relative h-[320px] w-full p-0 self-center overflow-hidden rounded-l-xl border md:flex-1",
|
||||
},
|
||||
{
|
||||
title: "Unparalleled VAR flexibility",
|
||||
description: [
|
||||
"VAR has a reputation for taking too long. At Charter, we optimise for the fastest VAR transfers possible — often in a matter of hours.",
|
||||
"Unlike legacy banks and middleware providers, we have a direct connection to the Federal Reserve to facilitate the quickest transfers.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/why-charter/2.svg",
|
||||
alt: "VAR Process Flow",
|
||||
width: 283,
|
||||
height: 45,
|
||||
},
|
||||
className: "md:col-span-2 flex flex-col justify-center",
|
||||
contentClassName:
|
||||
"flex items-center justify-center p-6 max-md:mt-4 max-md:mb-8",
|
||||
imagePosition: "content",
|
||||
},
|
||||
{
|
||||
title: "Unparalleled VAR flexibility",
|
||||
description: [
|
||||
"VAR has a reputation for taking too long. At Charter, we optimise for the fastest VAR transfers possible — often in a matter of hours.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/why-charter/3.svg",
|
||||
alt: "VAR Process Diagram",
|
||||
width: 283,
|
||||
height: 45,
|
||||
},
|
||||
className: "md:col-span-2",
|
||||
headerClassName: "h-full",
|
||||
imagePosition: "header",
|
||||
},
|
||||
{
|
||||
title: "Unparalleled VAR flexibility",
|
||||
description: [
|
||||
"Unlike traditional banks and middleware, we connect directly with the Federal Reserve to ensure the fastest, most transparent transfers possible.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/code-snippet.webp",
|
||||
alt: "Code snippet",
|
||||
type: "fill",
|
||||
},
|
||||
className: "overflow-hidden md:col-span-3 ",
|
||||
headerClassName: "",
|
||||
contentClassName:
|
||||
"relative h-[242px] mt-2 p-0 ml-8 w-full md:max-w-[400px] lg:max-w-[500px] overflow-hidden md:mx-auto shadow-xl rounded-t-2xl",
|
||||
},
|
||||
];
|
||||
|
||||
const FeaturedPosts = () => {
|
||||
return (
|
||||
<section
|
||||
id="featured-posts"
|
||||
className="container relative py-16 md:py-28 lg:py-32"
|
||||
>
|
||||
<h3 className="mini-title">Featured Posts</h3>
|
||||
|
||||
<div className="relative z-10 mt-8 grid grid-cols-1 gap-6 md:grid-cols-5">
|
||||
{items.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn("col-span-1 shadow-xl", item.className)}
|
||||
>
|
||||
<CardHeader className={item.headerClassName}>
|
||||
{item.imagePosition === "header" && (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt}
|
||||
width={item.image.width}
|
||||
height={item.image.height}
|
||||
className="flex-1 self-center max-md:mb-8 max-md:mt-4 dark:[filter:brightness(0)_saturate(100%)_invert(100%)]"
|
||||
/>
|
||||
)}
|
||||
<CardTitle className="text-3xl">{item.title}</CardTitle>
|
||||
{item.description.map((desc, i) => (
|
||||
<CardDescription
|
||||
key={i}
|
||||
className="mt-2 text-base font-medium leading-snug"
|
||||
>
|
||||
{desc}
|
||||
</CardDescription>
|
||||
))}
|
||||
</CardHeader>
|
||||
<CardContent className={item.contentClassName}>
|
||||
{item.image.type === "fill" ? (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt}
|
||||
className="size-full object-cover object-left-top"
|
||||
/>
|
||||
) : (
|
||||
item.imagePosition === "content" && (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt}
|
||||
width={item.image.width}
|
||||
height={item.image.height}
|
||||
className="self-center dark:[filter:brightness(0)_saturate(100%)_invert(100%)]"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<>
|
||||
<div className="absolute inset-0 isolate will-change-transform">
|
||||
<div className="bg-primary-gradient/28 absolute top-1/2 size-[700px] -translate-y-1/2 rounded-full blur-[300px]" />
|
||||
<div className="bg-secondary-gradient/16 absolute right-0 top-1/2 size-[700px] -translate-y-1/2 -rotate-12 rounded-full blur-[300px]" />
|
||||
<div className="bg-tertiary-gradient/6 absolute bottom-1/4 right-20 z-[1] h-[500px] w-[800px] -rotate-12 rounded-full blur-[100px] md:bottom-10" />
|
||||
</div>
|
||||
<div className="absolute -inset-x-20 top-0 [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_85%)]">
|
||||
<PlusSigns className="text-foreground/[0.075] h-full w-full" />
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedPosts;
|
||||
86
sites/vibes/src/components/sections/footer.tsx
Normal file
86
sites/vibes/src/components/sections/footer.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { FaXTwitter, FaLinkedin, FaFacebook } from "react-icons/fa6";
|
||||
import { Home } from "lucide-react";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
title: "Products",
|
||||
links: [
|
||||
{ name: "VAR", href: "/#code-security" },
|
||||
{ name: "Credit Transfers", href: "/#why-charter" },
|
||||
{ name: "Credit Accounts", href: "/#ai-chatbot" },
|
||||
{ name: "Loan Origination", href: "/#ai-chatbot" },
|
||||
{ name: "Loan Purchase", href: "/#ai-chatbot" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Support",
|
||||
links: [
|
||||
{ name: "Pricing", href: "/pricing" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
{ name: "Demo", href: "/contact" },
|
||||
{ name: "Contact", href: "/contact" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Company",
|
||||
links: [
|
||||
{ name: "About", href: "/about" },
|
||||
{ name: "Terms of Service", href: "/terms" },
|
||||
{ name: "Privacy Policy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: FaXTwitter, href: "https://twitter.com", label: "Twitter" },
|
||||
{ icon: FaFacebook, href: "https://facebook.com", label: "Facebook" },
|
||||
{ icon: FaLinkedin, href: "https://linkedin.com", label: "LinkedIn" },
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="pt-16 md:pt-28 lg:pt-32">
|
||||
<div className="w-full px-4 md:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* Navigation Section */}
|
||||
<nav className="flex flex-wrap justify-between gap-x-32 gap-y-20 border-b pb-14 lg:pb-20">
|
||||
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="py-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Home className="h-6 w-6" />
|
||||
<p className="text-sm font-medium">
|
||||
© {new Date().getFullYear()} Charter -{" "}
|
||||
<a
|
||||
href="https://shadcnblocks.com"
|
||||
className="underline transition-opacity hover:opacity-80"
|
||||
target="_blank"
|
||||
>
|
||||
Shadcnblocks.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{socialLinks.map((link) => (
|
||||
<a
|
||||
aria-label={link.label}
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="hover:text-muted-foreground"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<link.icon />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
130
sites/vibes/src/components/sections/hero.tsx
Normal file
130
sites/vibes/src/components/sections/hero.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import {
|
||||
ChevronRight,
|
||||
Wallet,
|
||||
Waypoints,
|
||||
Building2,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const features = [
|
||||
{
|
||||
id: "var",
|
||||
title: "Virtual Account",
|
||||
description: "Sed do eiusmod tempor incididunt ut labore",
|
||||
icon: Wallet,
|
||||
image: "/images/homepage/hero/1.webp",
|
||||
},
|
||||
{
|
||||
id: "wire",
|
||||
title: "Wire",
|
||||
description: "Sed do eiusmod tempor incididunt ut labore",
|
||||
icon: Waypoints,
|
||||
image: "/images/homepage/hero/2.webp",
|
||||
},
|
||||
{
|
||||
id: "bank-accounts",
|
||||
title: "Bank Accounts",
|
||||
description: "Sed do eiusmod tempor incididunt ut labore",
|
||||
icon: Building2,
|
||||
image: "/images/homepage/hero/3.webp",
|
||||
},
|
||||
{
|
||||
id: "bank-transfers",
|
||||
title: "Bank Transfers",
|
||||
description: "Sed do eiusmod tempor incididunt ut labore",
|
||||
icon: ArrowLeftRight,
|
||||
image: "/images/homepage/hero/4.webp",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section className="bg-background relative overflow-hidden pt-16 md:pt-28 lg:pt-32">
|
||||
<div className="relative z-10">
|
||||
<div className="container max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tighter md:text-5xl lg:text-6xl">
|
||||
Secure. Composable. Bankable.
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-mona mt-4 text-balance text-2xl md:text-3xl">
|
||||
Charter is the fit-for-purpose developer API for building robust,
|
||||
encrypted finance products.
|
||||
</p>
|
||||
<div className="mt-7">
|
||||
<Button asChild size="lg">
|
||||
<a href="/signup">
|
||||
Start building for free
|
||||
<ChevronRight className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{features.map((feature) => (
|
||||
<img
|
||||
key={feature.id}
|
||||
src={feature.image}
|
||||
alt={feature.title}
|
||||
className="lg:translate-y-15 hidden size-full translate-y-8 object-contain"
|
||||
/>
|
||||
))}
|
||||
<Tabs defaultValue="var" className="mt-8 md:mt-12 lg:mt-20">
|
||||
{/* Tab Content */}
|
||||
<div className="container">
|
||||
{features.map((feature) => (
|
||||
<TabsContent
|
||||
key={feature.id}
|
||||
value={feature.id}
|
||||
className="relative aspect-[2.116/1] overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={feature.image}
|
||||
alt={feature.title}
|
||||
className="lg:translate-y-15 size-full translate-y-8 object-contain"
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Triggers */}
|
||||
<div className="bg-background pb-16 pt-12 md:pb-28 lg:pb-32">
|
||||
<TabsList className="mx-auto flex h-auto max-w-4xl justify-start gap-4 overflow-x-auto bg-transparent max-lg:px-5">
|
||||
{features.map((feature) => (
|
||||
<TabsTrigger
|
||||
key={feature.id}
|
||||
value={feature.id}
|
||||
className="ring-secondary-foreground group min-w-[200px] flex-1 justify-start whitespace-normal rounded-lg px-4 py-3 text-start transition-colors duration-300 data-[state=active]:bg-transparent data-[state=active]:ring lg:px-6 lg:py-4"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-muted-foreground/40 group-data-[state=active]:bg-secondary-foreground flex size-8 items-center justify-center rounded-md p-1.5">
|
||||
<feature.icon className="stroke-background" />
|
||||
</div>
|
||||
<h2 className="group-data-[state=active]:text-primary font-inter text-foreground mt-2 text-lg font-bold">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 aspect-square [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_75%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
<div className="">
|
||||
{/* will-change-transform improves performance on scroll on safari because of the high blur */}
|
||||
<div className="bg-primary-gradient/11 absolute inset-x-[0%] bottom-0 left-0 h-[500px] rounded-full blur-[100px] will-change-transform md:h-[950px]" />
|
||||
<div className="bg-secondary-gradient/9 absolute inset-x-[30%] bottom-0 right-0 h-[500px] rounded-full blur-[100px] will-change-transform md:h-[950px]" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
61
sites/vibes/src/components/sections/join-us.tsx
Normal file
61
sites/vibes/src/components/sections/join-us.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Button } from "../ui/button";
|
||||
|
||||
const JoinUs = () => {
|
||||
const jobCategories = [
|
||||
{
|
||||
name: "Engineering",
|
||||
jobs: [
|
||||
{ title: "iOS Developer", location: "Remote" },
|
||||
{ title: "Backend Engineer", location: "Remote" },
|
||||
{ title: "Frontend Engineer", location: "Remote" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Design",
|
||||
jobs: [
|
||||
{ title: "Senior Designer", location: "Remote" },
|
||||
{ title: "Staff Designer", location: "Remote" },
|
||||
{ title: "Designer", location: "Remote" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="lg:py-15 container max-w-5xl py-10 md:py-12">
|
||||
<div className="border-t pt-5">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-4xl font-semibold tracking-tight md:text-4xl">
|
||||
Join us
|
||||
</h2>
|
||||
<p className="text-muted-foreground font-mona lg:pb-15 mt-4 max-w-2xl pb-10 text-2xl md:pb-12 md:text-3xl">
|
||||
We work together from all over the world.
|
||||
</p>
|
||||
|
||||
{jobCategories.map((category, categoryIndex) => (
|
||||
<div key={categoryIndex}>
|
||||
<h3 className="border-foreground border-b py-6 text-lg font-semibold">
|
||||
{category.name}
|
||||
</h3>
|
||||
<div className="">
|
||||
{category.jobs.map((job, jobIndex) => (
|
||||
<div
|
||||
key={`${categoryIndex}-${jobIndex}`}
|
||||
className="flex items-center justify-between gap-10 border-b py-3 md:gap-16 lg:gap-28"
|
||||
>
|
||||
<h4 className="flex-1 font-medium">{job.title}</h4>
|
||||
<p className="text-muted-foreground">{job.location}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="#">View listing</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default JoinUs;
|
||||
110
sites/vibes/src/components/sections/latest-posts.tsx
Normal file
110
sites/vibes/src/components/sections/latest-posts.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { PlusSigns } from "@/components/icons/plus-signs";
|
||||
import Testimonials from "@/components/sections/testimonials";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
category: "SUPPORT",
|
||||
items: [
|
||||
{
|
||||
question: "Is there a free version?",
|
||||
answer:
|
||||
"Yes! We offer a generous free plan with just enough features except that one feature you really want! Our strategy is to get your credit card details on file then steadily double our prices against inflation rates.",
|
||||
},
|
||||
{
|
||||
question: "How do I update my account without breaking my laptop?",
|
||||
answer:
|
||||
"Our platform is designed with safety in mind. You can update your account settings through our intuitive dashboard without any risk to your hardware. We have multiple safeguards in place to prevent any system conflicts.",
|
||||
},
|
||||
{
|
||||
question: "Is support free, or do I need to Google everything?",
|
||||
answer:
|
||||
"We provide comprehensive support at no additional cost. Our dedicated support team is available 24/7 to help you with any questions or issues you might encounter. No need to rely on Google - we're here to help!",
|
||||
},
|
||||
{
|
||||
question: "Are you going to be subsumed by AI?",
|
||||
answer:
|
||||
"While we embrace AI technology to enhance our services, we maintain a strong human element in our operations. Our team works alongside AI to provide the best possible service while ensuring human oversight and decision-making remain central to our operations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "YOUR ACCOUNT",
|
||||
items: [
|
||||
{
|
||||
question: "Is support free, or do I need to Google everything?",
|
||||
answer:
|
||||
"We provide comprehensive support at no additional cost. Our dedicated support team is available 24/7 to help you with any questions or issues you might encounter. No need to rely on Google - we're here to help!",
|
||||
},
|
||||
{
|
||||
question: "Are you going to be subsumed by AI?",
|
||||
answer:
|
||||
"While we embrace AI technology to enhance our services, we maintain a strong human element in our operations. Our team works alongside AI to provide the best possible service while ensuring human oversight and decision-making remain central to our operations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "OTHER QUESTIONS",
|
||||
items: [
|
||||
{
|
||||
question: "Is support free, or do I need to Google everything?",
|
||||
answer:
|
||||
"We provide comprehensive support at no additional cost. Our dedicated support team is available 24/7 to help you with any questions or issues you might encounter. No need to rely on Google - we're here to help!",
|
||||
},
|
||||
{
|
||||
question: "Are you going to be subsumed by AI?",
|
||||
answer:
|
||||
"While we embrace AI technology to enhance our services, we maintain a strong human element in our operations. Our team works alongside AI to provide the best possible service while ensuring human oversight and decision-making remain central to our operations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function LatestPosts() {
|
||||
return (
|
||||
<>
|
||||
<section className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto mt-8 max-w-2xl space-y-12 md:mt-12 lg:mt-20">
|
||||
{FAQ_ITEMS.map((category) => (
|
||||
<Card key={category.category} className="border-hidden">
|
||||
<CardHeader className="pb-0">
|
||||
<h3 className="text-accent-foreground border-b pb-4 font-mono text-sm font-medium uppercase tracking-widest">
|
||||
{category.category}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{category.items.map((item, i) => (
|
||||
<AccordionItem
|
||||
key={i}
|
||||
value={`${category.category}-${i}`}
|
||||
className="border-muted border-b last:border-0"
|
||||
>
|
||||
<AccordionTrigger className="text-base font-medium hover:no-underline">
|
||||
{item.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground text-base font-medium">
|
||||
{item.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -inset-40 z-[-1] [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_75%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
sites/vibes/src/components/sections/launch-pricing.tsx
Normal file
86
sites/vibes/src/components/sections/launch-pricing.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { ChevronRight, Check } from "lucide-react";
|
||||
|
||||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function LaunchPricing() {
|
||||
return (
|
||||
<section
|
||||
id="launch-today"
|
||||
className="text-background py-16 md:container md:max-w-5xl md:py-28 lg:py-32"
|
||||
>
|
||||
<div className="from-primary-900 to-primary/90 divide-background/20 bg-linear-to-r container relative isolate grid items-center overflow-hidden py-8 max-lg:gap-10 max-md:gap-6 md:rounded-3xl lg:grid-cols-2 lg:divide-x lg:px-8">
|
||||
<div className="absolute inset-0 -z-10 [mask-image:linear-gradient(to_left,black_50%,transparent_100%)]">
|
||||
<PlusSigns className="text-background/[0.05] h-full w-full" />
|
||||
</div>
|
||||
<div className="lg:py-16 lg:pr-20">
|
||||
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl lg:text-5xl">
|
||||
Launch today
|
||||
</h2>
|
||||
<p className="text-background/70 mt-3 text-sm font-medium">
|
||||
In the past, new financial companies had to rely on expensive
|
||||
middleware that linked them to outdated sponsor bank systems,
|
||||
restricting their potential. Our API solves this today.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-4 max-md:hidden">
|
||||
<Button size="lg" variant="secondary" className="group" asChild>
|
||||
<a href="/signup">
|
||||
Start for free
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="lg" className="bg-secondary-foreground group" asChild>
|
||||
<a href="/contact">
|
||||
Get a demo
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 lg:py-10 lg:pl-20">
|
||||
<div>
|
||||
<h3 className="text-background text-3xl font-semibold md:text-4xl lg:text-5xl">
|
||||
$29.99
|
||||
</h3>
|
||||
<p className="text-background/70 mt-1 text-xl font-medium">
|
||||
per user per month
|
||||
</p>
|
||||
</div>
|
||||
<ul className="text-background/70 space-y-3 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="size-4" />
|
||||
All free plan features and...
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="size-4" />
|
||||
Mainline AI
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="size-4" />
|
||||
Unlimited teams
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-10 flex flex-wrap gap-4 md:hidden">
|
||||
<Button size="lg" variant="secondary" className="group w-full">
|
||||
<a href="/signup" className="flex items-center gap-2">
|
||||
Start building for free
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-secondary-foreground border-background/20 group w-full border"
|
||||
>
|
||||
<a href="/contact" className="flex items-center gap-2">
|
||||
Get a demo
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
75
sites/vibes/src/components/sections/login-section.tsx
Normal file
75
sites/vibes/src/components/sections/login-section.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Card, CardContent, CardHeader } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
|
||||
const LoginSection = () => {
|
||||
return (
|
||||
<section className="bg-sand-100 py-16 md:py-28 lg:py-32">
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="mx-auto w-full max-w-sm">
|
||||
<CardHeader className="flex flex-col items-center space-y-0">
|
||||
<img
|
||||
src="/images/layout/logo.svg"
|
||||
alt="logo"
|
||||
width={94}
|
||||
height={18}
|
||||
className="mb-7 dark:invert"
|
||||
/>
|
||||
<p className="mb-2 text-2xl font-bold">Welcome back</p>
|
||||
<p className="text-muted-foreground">
|
||||
Please enter your details.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<Input type="email" placeholder="Enter your email" required />
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
className="border-muted-foreground"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
<a href="#" className="text-primary text-sm font-medium">
|
||||
Forgot password
|
||||
</a>
|
||||
</div>
|
||||
<Button type="submit" className="mt-2 w-full">
|
||||
Create an account
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
<FcGoogle className="mr-2 size-5" />
|
||||
Sign up with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-muted-foreground mx-auto mt-8 flex justify-center gap-1 text-sm">
|
||||
<p>Don't have an account?</p>
|
||||
<a href="/signup" className="text-primary font-medium">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginSection;
|
||||
111
sites/vibes/src/components/sections/logos.tsx
Normal file
111
sites/vibes/src/components/sections/logos.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
name: "Mercury",
|
||||
src: "/images/logos/mercury.svg",
|
||||
width: 143,
|
||||
height: 26,
|
||||
href: "https://mercury.com",
|
||||
},
|
||||
{
|
||||
name: "Watershed",
|
||||
src: "/images/logos/watershed.svg",
|
||||
width: 154,
|
||||
height: 31,
|
||||
href: "https://watershed.com",
|
||||
},
|
||||
{
|
||||
name: "Retool",
|
||||
src: "/images/logos/retool.svg",
|
||||
width: 113,
|
||||
height: 22,
|
||||
href: "https://retool.com",
|
||||
},
|
||||
{
|
||||
name: "Descript",
|
||||
src: "/images/logos/descript.svg",
|
||||
width: 112,
|
||||
height: 27,
|
||||
href: "https://descript.com",
|
||||
},
|
||||
{
|
||||
name: "Perplexity",
|
||||
src: "/images/logos/perplexity.svg",
|
||||
width: 141,
|
||||
height: 32,
|
||||
href: "https://perplexity.ai",
|
||||
},
|
||||
{
|
||||
name: "Monzo",
|
||||
src: "/images/logos/monzo.svg",
|
||||
width: 104,
|
||||
height: 18,
|
||||
href: "https://monzo.com",
|
||||
},
|
||||
{
|
||||
name: "Ramp",
|
||||
src: "/images/logos/ramp.svg",
|
||||
width: 105,
|
||||
height: 28,
|
||||
href: "https://ramp.com",
|
||||
},
|
||||
{
|
||||
name: "Raycast",
|
||||
src: "/images/logos/raycast.svg",
|
||||
width: 128,
|
||||
height: 33,
|
||||
href: "https://raycast.com",
|
||||
},
|
||||
{
|
||||
name: "Arc",
|
||||
src: "/images/logos/arc.svg",
|
||||
width: 90,
|
||||
height: 28,
|
||||
href: "https://arc.com",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Logos() {
|
||||
return (
|
||||
<section className="overflow-hidden">
|
||||
<h2 className="text-muted-foreground text-center text-2xl">
|
||||
From next-gen startups to established enterprises.
|
||||
</h2>
|
||||
|
||||
<div className="relative mt-10 flex w-full">
|
||||
<div className="from-background bg-linear-to-r absolute left-0 z-20 h-full w-10 to-transparent" />
|
||||
<div className="from-background bg-linear-to-l absolute right-0 z-20 h-full w-10 to-transparent" />
|
||||
|
||||
{/* First marquee group */}
|
||||
<div className="animate-marquee flex shrink-0 items-center gap-12">
|
||||
{ITEMS.map((logo, index) => (
|
||||
<a href={logo.href} target="_blank" key={index} className="p-6">
|
||||
<img
|
||||
src={logo.src}
|
||||
alt={logo.name}
|
||||
width={logo.width}
|
||||
height={logo.height}
|
||||
className="object-contain transition-opacity hover:opacity-70"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{/* Second marquee group */}
|
||||
<div className="animate-marquee flex shrink-0 items-center gap-12">
|
||||
{ITEMS.map((logo, index) => (
|
||||
<a href={logo.href} target="_blank" key={index} className="p-6">
|
||||
<img
|
||||
src={logo.src}
|
||||
alt={logo.name}
|
||||
width={logo.width}
|
||||
height={logo.height}
|
||||
className="object-contain transition-opacity hover:opacity-70"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
133
sites/vibes/src/components/sections/navbar.tsx
Normal file
133
sites/vibes/src/components/sections/navbar.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { ChevronRight, Menu, X, Home } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeToggle } from "../elements/theme-toggle";
|
||||
|
||||
const Navbar = ({ currentPage }: { currentPage: string }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const pathname = currentPage;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
document.body.classList.add("overflow-hidden");
|
||||
} else {
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
const ITEMS = [
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Latest", href: "/latest" },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className={"relative z-50"}>
|
||||
<div className="container max-w-5xl lg:pt-10">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
{/* Logo */}
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
<Home className="h-8 w-8" />
|
||||
</a>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<NavigationMenu className="hidden items-center gap-8 lg:flex">
|
||||
<NavigationMenuList>
|
||||
{ITEMS.map((link) =>
|
||||
<NavigationMenuItem key={link.label}>
|
||||
<a
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"hover:text-accent-foreground p-2 lg:text-base",
|
||||
pathname === link.href && "text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
|
||||
{/* Auth Buttons */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
||||
<div
|
||||
className={`transition-opacity duration-300 ${isMenuOpen ? "max-lg:pointer-events-none max-lg:opacity-0" : "opacity-100"}`}
|
||||
>
|
||||
<ThemeToggle className="dark:bg-foreground dark:text-background" />
|
||||
</div>
|
||||
|
||||
{/* Hamburger Menu Button (Mobile Only) */}
|
||||
<button
|
||||
className="relative flex size-8 lg:hidden"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<div className="absolute left-1/2 top-1/2 block w-[18px] -translate-x-1/2 -translate-y-1/2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute block h-0.5 w-full rounded-full bg-current transition duration-500 ease-in-out ${isMenuOpen ? "rotate-45" : "-translate-y-1.5"}`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute block h-0.5 w-full rounded-full bg-current transition duration-500 ease-in-out ${isMenuOpen ? "opacity-0" : ""}`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute block h-0.5 w-full rounded-full bg-current transition duration-500 ease-in-out ${isMenuOpen ? "-rotate-45" : "translate-y-1.5"}`}
|
||||
></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background container absolute inset-0 top-full flex h-[calc(100vh-64px)] flex-col transition-all duration-300 ease-in-out lg:hidden",
|
||||
isMenuOpen
|
||||
? "visible translate-x-0 opacity-100"
|
||||
: "invisible translate-x-full opacity-0",
|
||||
)}
|
||||
>
|
||||
|
||||
<nav className="mt-3 flex flex-1 flex-col gap-6">
|
||||
{ITEMS.map((link) =>
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"text-lg tracking-[-0.36px]",
|
||||
pathname === link.href && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
64
sites/vibes/src/components/sections/newsletter-signup.tsx
Normal file
64
sites/vibes/src/components/sections/newsletter-signup.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Mail } from "lucide-react";
|
||||
|
||||
const NewsletterSignup = () => {
|
||||
const [email, setEmail] = React.useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// This would be connected to your newsletter service
|
||||
console.log("Submitting email:", email);
|
||||
alert("Thanks for subscribing!");
|
||||
setEmail("");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/50 border-t py-16 md:py-24">
|
||||
<div className="container max-w-5xl">
|
||||
<div className="bg-card flex flex-col items-center justify-between gap-8 rounded-2xl border p-8 shadow-sm md:p-12 lg:flex-row">
|
||||
<div className="max-w-md text-center lg:text-left">
|
||||
<div className="mb-3 flex justify-center lg:justify-start">
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-2xl font-bold md:text-3xl">
|
||||
Subscribe to our newsletter
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-0 text-base">
|
||||
Stay updated with the latest articles, tutorials, and insights
|
||||
from our team. We'll never spam your inbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="h-12"
|
||||
/>
|
||||
<Button type="submit" className="h-12">
|
||||
Subscribe
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
By subscribing, you agree to our Privacy Policy and consent to
|
||||
receive updates from our company.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterSignup;
|
||||
187
sites/vibes/src/components/sections/pricing-cards.tsx
Normal file
187
sites/vibes/src/components/sections/pricing-cards.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { Check, ChevronRight } from "lucide-react";
|
||||
|
||||
import { PlusSigns } from "../icons/plus-signs";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PricingTier = {
|
||||
name: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
cta: {
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ITEMS: PricingTier[] = [
|
||||
{
|
||||
name: "STARTER",
|
||||
price: "$0",
|
||||
description: "Free for everyone",
|
||||
features: ["Unlimited members", "250 transactions", "No support"],
|
||||
cta: {
|
||||
text: "Start for free",
|
||||
href: "/signup",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BASIC",
|
||||
price: "$29.99",
|
||||
description: "per user per month",
|
||||
features: [
|
||||
"All free plan features and...",
|
||||
"Mainline AI",
|
||||
"Unlimited teams",
|
||||
],
|
||||
cta: {
|
||||
text: "7 days free",
|
||||
href: "/signup",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ENTERPRISE",
|
||||
price: "$ENT",
|
||||
description: "Custom pricing",
|
||||
features: [
|
||||
"All basic plan features and...",
|
||||
"Advanced security controls",
|
||||
"Migration support",
|
||||
],
|
||||
cta: {
|
||||
text: "Book a demo",
|
||||
href: "/contact",
|
||||
},
|
||||
},
|
||||
];
|
||||
const PricingCards = () => {
|
||||
return (
|
||||
<section className="relative py-16 md:py-28 lg:py-32">
|
||||
<div className="container">
|
||||
<h1 className="text-center text-4xl font-semibold tracking-tight md:text-5xl lg:text-6xl">
|
||||
Pricing
|
||||
</h1>
|
||||
<div className="mx-auto mt-4 max-w-[45rem] space-y-2 text-center">
|
||||
<p className="text-muted-foreground text-2xl md:text-3xl">
|
||||
Use Charter for free with your whole team. Upgrade to enable
|
||||
enhanced features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-8 md:mt-12 lg:mt-20">
|
||||
{/* Background and layout wrapper */}
|
||||
<div className="from-primary-900 to-primary/90 bg-linear-to-r absolute inset-0 isolate hidden rounded-3xl md:block">
|
||||
<div className="absolute inset-0 -z-10 [mask-image:linear-gradient(to_left,black_50%,transparent_100%)]">
|
||||
<PlusSigns className="h-full w-full text-white/[0.05]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:divide-background/20 relative space-y-6 md:grid md:grid-cols-3 md:space-y-0 md:divide-x md:p-6 lg:p-8">
|
||||
{ITEMS.map((tier, index) => (
|
||||
<PricingCard
|
||||
key={tier.name}
|
||||
tier={tier}
|
||||
isHighlighted={index === 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -inset-40 z-[-1] [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_75%)]">
|
||||
<PlusSigns className="text-foreground/[0.05] h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCards;
|
||||
function PricingCard({
|
||||
tier,
|
||||
isHighlighted,
|
||||
}: {
|
||||
tier: PricingTier;
|
||||
isHighlighted: boolean;
|
||||
}) {
|
||||
const styles = {
|
||||
card: cn(
|
||||
"flex flex-col gap-6 rounded-xl p-6 sm:rounded-2xl md:rounded-none lg:p-8",
|
||||
// Mobile styles
|
||||
isHighlighted
|
||||
? "max-md:from-primary-900 max-md:to-primary/90 max-md:bg-linear-to-r"
|
||||
: "bg-background max-md:border",
|
||||
// Desktop styles
|
||||
"md:bg-transparent",
|
||||
),
|
||||
title: cn(
|
||||
"font-mono text-sm tracking-widest",
|
||||
// Mobile styles
|
||||
isHighlighted ? "text-background/70" : "text-foreground/70",
|
||||
// Desktop styles
|
||||
"md:text-background/70",
|
||||
),
|
||||
price: cn(
|
||||
"text-5xl font-semibold tracking-tight",
|
||||
// Mobile styles
|
||||
isHighlighted ? "text-background" : "text-foreground",
|
||||
// Desktop styles
|
||||
"md:text-background",
|
||||
),
|
||||
description: cn(
|
||||
"mt-2 text-xl font-medium",
|
||||
// Mobile styles
|
||||
isHighlighted ? "text-background/70" : "text-foreground/70",
|
||||
// Desktop styles
|
||||
"md:text-background/70",
|
||||
),
|
||||
features: cn(
|
||||
"space-y-3 text-sm",
|
||||
// Mobile styles
|
||||
isHighlighted ? "text-background/70" : "text-foreground/70",
|
||||
// Desktop styles
|
||||
"md:text-background/70",
|
||||
),
|
||||
button: cn(
|
||||
"group border-foreground/20 relative w-full",
|
||||
// inset shadow
|
||||
"after:from-border after:via-border after:absolute after:inset-0 after:bg-linear-to-t after:to-transparent after:content-[''] after:group-hover:opacity-100",
|
||||
// Desktop styles
|
||||
"md:border-background/40 md:text-background md:bg-transparent",
|
||||
isHighlighted &&
|
||||
"md:bg-background md:text-primary hover:md:bg-background/90",
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h3 className={styles.title}>{tier.name}</h3>
|
||||
<div>
|
||||
<p className={styles.price}>{tier.price}</p>
|
||||
<p className={styles.description}>{tier.description}</p>
|
||||
</div>
|
||||
<ul className={styles.features}>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2">
|
||||
<Check className="size-4 shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-1 items-end">
|
||||
<Button
|
||||
asChild
|
||||
variant={isHighlighted ? "secondary" : "outline"}
|
||||
size="lg"
|
||||
className={styles.button}
|
||||
>
|
||||
<a href={tier.cta.href}>
|
||||
{tier.cta.text}
|
||||
<ChevronRight className="ml-1 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
sites/vibes/src/components/sections/pricing-table.tsx
Normal file
304
sites/vibes/src/components/sections/pricing-table.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PlanType = "free" | "startup" | "enterprise";
|
||||
|
||||
interface Plan {
|
||||
name: string;
|
||||
type: PlanType;
|
||||
button: {
|
||||
text: string;
|
||||
variant: "outline";
|
||||
href: string;
|
||||
};
|
||||
features: {
|
||||
[Category in "usage" | "features" | "support"]: {
|
||||
name: string;
|
||||
value: string | boolean;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const plans: Plan[] = [
|
||||
{
|
||||
name: "Free",
|
||||
type: "free",
|
||||
button: {
|
||||
text: "Get started",
|
||||
variant: "outline",
|
||||
href: "/signup",
|
||||
},
|
||||
features: {
|
||||
usage: [
|
||||
{ name: "Members", value: "Unlimited" },
|
||||
{ name: "Transactions", value: "250" },
|
||||
{ name: "Teams", value: "2" },
|
||||
],
|
||||
features: [
|
||||
{ name: "Reporting", value: true },
|
||||
{ name: "Analytics", value: true },
|
||||
{ name: "Import and export", value: true },
|
||||
{ name: "Integrations", value: true },
|
||||
{ name: "Mainline AI", value: false },
|
||||
{ name: "Admin roles", value: false },
|
||||
{ name: "Audit log", value: false },
|
||||
],
|
||||
support: [
|
||||
{ name: "Priority Support", value: true },
|
||||
{ name: "Account Manager", value: false },
|
||||
{ name: "Uptime SLA", value: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Basic",
|
||||
type: "startup",
|
||||
button: {
|
||||
text: "Get started",
|
||||
variant: "outline",
|
||||
href: "/signup",
|
||||
},
|
||||
features: {
|
||||
usage: [
|
||||
{ name: "Members", value: "Unlimited" },
|
||||
{ name: "Transactions", value: "Unlimited" },
|
||||
{ name: "Teams", value: "Unlimited" },
|
||||
],
|
||||
features: [
|
||||
{ name: "Reporting", value: true },
|
||||
{ name: "Analytics", value: true },
|
||||
{ name: "Import and export", value: true },
|
||||
{ name: "Integrations", value: true },
|
||||
{ name: "Mainline AI", value: true },
|
||||
{ name: "Admin roles", value: false },
|
||||
{ name: "Audit log", value: false },
|
||||
],
|
||||
support: [
|
||||
{ name: "Priority Support", value: true },
|
||||
{ name: "Account Manager", value: false },
|
||||
{ name: "Uptime SLA", value: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Ent",
|
||||
type: "enterprise",
|
||||
button: {
|
||||
text: "Get a demo",
|
||||
variant: "outline",
|
||||
href: "/contact",
|
||||
},
|
||||
features: {
|
||||
usage: [
|
||||
{ name: "Members", value: "Unlimited" },
|
||||
{ name: "Transactions", value: "Unlimited" },
|
||||
{ name: "Teams", value: "Unlimited" },
|
||||
],
|
||||
features: [
|
||||
{ name: "Reporting", value: true },
|
||||
{ name: "Analytics", value: true },
|
||||
{ name: "Import and export", value: true },
|
||||
{ name: "Integrations", value: true },
|
||||
{ name: "Mainline AI", value: true },
|
||||
{ name: "Admin roles", value: false },
|
||||
{ name: "Audit log", value: false },
|
||||
],
|
||||
support: [
|
||||
{ name: "Priority Support", value: true },
|
||||
{ name: "Account Manager", value: true },
|
||||
{ name: "Uptime SLA", value: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingTable() {
|
||||
const [selectedPlan, setSelectedPlan] = useState(1);
|
||||
|
||||
return (
|
||||
<section className="py-28 lg:py-32">
|
||||
<div className="container font-medium">
|
||||
<MobilePricingTable
|
||||
selectedPlan={selectedPlan}
|
||||
onPlanChange={setSelectedPlan}
|
||||
/>
|
||||
<DesktopPricingTable />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const MobilePricingTable = ({
|
||||
selectedPlan,
|
||||
onPlanChange,
|
||||
}: {
|
||||
selectedPlan: number;
|
||||
onPlanChange: (index: number) => void;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const plan = plans[selectedPlan];
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="flex items-center justify-between border-b py-4">
|
||||
<CollapsibleTrigger className="flex items-center gap-2">
|
||||
<h3 className="text-2xl font-semibold">{plan.name}</h3>
|
||||
<ChevronsUpDown
|
||||
className={`size-5 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<Button variant={plan.button.variant} className="w-fit">
|
||||
{plan.button.text}
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent className="flex flex-col space-y-2 p-2">
|
||||
{plans.map(
|
||||
(p, index) =>
|
||||
index !== selectedPlan && (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onPlanChange(index);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Features List */}
|
||||
<div className="mt-8">
|
||||
{Object.entries(plan.features).map(
|
||||
([category, features], sectionIndex) => (
|
||||
<div key={sectionIndex} className="mb-8 space-y-2">
|
||||
<h3 className="mb-4 text-lg font-semibold capitalize">
|
||||
{category}
|
||||
</h3>
|
||||
{features.map((feature, featureIndex) => (
|
||||
<div
|
||||
key={featureIndex}
|
||||
className="grid grid-cols-2 items-center gap-8"
|
||||
>
|
||||
<span className="border-b py-2">{feature.name}</span>
|
||||
<div className="flex items-center gap-1 border-b py-2">
|
||||
{typeof feature.value === "boolean" ? (
|
||||
feature.value ? (
|
||||
<Check className="size-5" />
|
||||
) : (
|
||||
<span className="size-5" />
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Check className="size-5" />
|
||||
<span>{feature.value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopPricingTable = () => {
|
||||
return (
|
||||
<div className="hidden md:grid md:grid-cols-4">
|
||||
<FeaturesColumn />
|
||||
{plans.map((plan, index) => (
|
||||
<PricingColumn
|
||||
key={plan.name}
|
||||
plan={plan}
|
||||
isHighlighted={index === 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesColumn = () => (
|
||||
<div>
|
||||
<div className="h-[140px]" /> {/* Spacer for plan header alignment */}
|
||||
{Object.entries(plans[0].features).map(([category, features], index) => (
|
||||
<div key={index}>
|
||||
<h3 className="flex h-20 items-center text-lg font-semibold capitalize">
|
||||
{category}
|
||||
</h3>
|
||||
{features.map((feature, featureIndex) => (
|
||||
<div key={featureIndex} className="py-4">
|
||||
{feature.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PricingColumn = ({
|
||||
plan,
|
||||
isHighlighted,
|
||||
}: {
|
||||
plan: Plan;
|
||||
isHighlighted: boolean;
|
||||
}) => {
|
||||
const columnClass = cn("px-6", isHighlighted && "bg-card border rounded-xl");
|
||||
|
||||
return (
|
||||
<div className={columnClass}>
|
||||
{/* Plan Header */}
|
||||
<div className="py-8">
|
||||
<h3 className="mb-3 text-2xl font-semibold">{plan.name}</h3>
|
||||
<Button variant={plan.button.variant} asChild>
|
||||
<a href={plan.button.href}>{plan.button.text}</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
{Object.entries(plan.features).map(([, features], sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<div className="flex h-20 items-center"></div> {/* space */}
|
||||
{features.map((feature, featureIndex) => (
|
||||
<div
|
||||
key={featureIndex}
|
||||
className="flex items-center gap-1 border-b py-4"
|
||||
>
|
||||
{typeof feature.value === "boolean" ? (
|
||||
feature.value ? (
|
||||
<Check className="size-5" />
|
||||
) : (
|
||||
<span className="size-5" />
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Check className="size-4" />
|
||||
<span>{feature.value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
sites/vibes/src/components/sections/signup-section.tsx
Normal file
61
sites/vibes/src/components/sections/signup-section.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Card, CardContent, CardHeader } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
|
||||
const SignupSection = () => {
|
||||
return (
|
||||
<section className="bg-sand-100 py-16 md:py-28 lg:py-32">
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="mx-auto w-full max-w-sm">
|
||||
<CardHeader className="flex flex-col items-center space-y-0">
|
||||
<img
|
||||
src="/images/layout/logo.svg"
|
||||
alt="logo"
|
||||
width={94}
|
||||
height={18}
|
||||
className="mb-7 dark:invert"
|
||||
/>
|
||||
<p className="mb-2 text-2xl font-bold">Start your free trial</p>
|
||||
<p className="text-muted-foreground">
|
||||
Sign up in less than 2 minutes.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<Input type="text" placeholder="Enter your name" required />
|
||||
<Input type="email" placeholder="Enter your email" required />
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Must be at least 8 characters.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="mt-2 w-full">
|
||||
Create an account
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
<FcGoogle className="mr-2 size-5" />
|
||||
Sign up with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-muted-foreground mx-auto mt-8 flex justify-center gap-1 text-sm">
|
||||
<p>Already have an account?</p>
|
||||
<a href="/login" className="text-primary font-medium">
|
||||
Log in
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupSection;
|
||||
148
sites/vibes/src/components/sections/testimonials.tsx
Normal file
148
sites/vibes/src/components/sections/testimonials.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
id: 1,
|
||||
quote:
|
||||
"Charter's platform delivers unmatched speed, a flexible account framework, and an API-first design. Their deep understanding of the ecosystem has been crucial for scaling our financial operations effectively.",
|
||||
author: "Henry Francis",
|
||||
role: "Founder, Ramp",
|
||||
logo: "/images/logos/ramp.svg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
quote:
|
||||
"We have a deep understanding of the ecosystem, and Charter delivers unmatched speed, a flexible account framework, and an API-first design. These features are crucial for creating revenue that grows our office plants.",
|
||||
author: "David Chen",
|
||||
role: "Head of Engineering, Notion",
|
||||
logo: "/images/logos/notion.svg",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
quote:
|
||||
"Charter's platform has exceeded our expectations with its seamless integration process and exceptional support team. Their powerful tools and robust infrastructure have been instrumental in our scaling journey.",
|
||||
author: "Sarah Williams",
|
||||
role: "CTO, Mercury",
|
||||
logo: "/images/logos/mercury.svg",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
quote:
|
||||
"Charter has revolutionized how we manage our financial infrastructure effectively. Their API-first approach and robust platform capabilities have given us the flexibility to build custom solutions at scale seamlessly and efficiently.",
|
||||
author: "Michael Ross",
|
||||
role: "CEO, Raycast",
|
||||
logo: "/images/logos/raycast.svg",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
quote:
|
||||
"The combination of Charter's flexible platform architecture and exceptional support team has been transformative for our operations. Their speed, reliability, and scalability are unmatched in the industry.",
|
||||
author: "Emily Chen",
|
||||
role: "Product Lead, Asana",
|
||||
logo: "/images/logos/asana.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Testimonials() {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative py-16 md:py-28 lg:py-32"
|
||||
aria-label="Customer Testimonials"
|
||||
>
|
||||
<div className="container max-w-4xl">
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "center",
|
||||
loop: true,
|
||||
}}
|
||||
setApi={setApi}
|
||||
>
|
||||
<CarouselContent>
|
||||
{TESTIMONIALS.map((testimonial) => (
|
||||
<CarouselItem
|
||||
key={testimonial.id}
|
||||
className="flex cursor-grab flex-col gap-6 lg:gap-8"
|
||||
>
|
||||
<blockquote className="pointer-events-none select-none text-balance font-sans text-2xl font-semibold leading-tight tracking-tight md:text-3xl lg:text-4xl">
|
||||
{testimonial.quote}
|
||||
</blockquote>
|
||||
<div className="flex items-center gap-4">
|
||||
{testimonial.logo && (
|
||||
<div className="relative h-8 w-24">
|
||||
<img
|
||||
src={testimonial.logo}
|
||||
alt={`${testimonial.author}'s company logo`}
|
||||
className="size-full object-contain dark:invert"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-border h-8 w-[1px]" aria-hidden="true" />
|
||||
<div>
|
||||
<cite className="font-semibold not-italic">
|
||||
{testimonial.author}
|
||||
</cite>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
{testimonial.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
<div
|
||||
className="mt-10 flex gap-2 lg:mt-16"
|
||||
role="tablist"
|
||||
aria-label="Testimonials navigation"
|
||||
>
|
||||
{TESTIMONIALS.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
"size-4 cursor-pointer rounded-full transition-colors duration-300",
|
||||
index === current
|
||||
? "bg-muted-foreground"
|
||||
: "bg-muted-foreground/20 hover:bg-muted-foreground/50",
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
aria-label={`Go to testimonial ${index + 1}`}
|
||||
aria-selected={index === current}
|
||||
role="tab"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 top-16 isolate z-[-1] h-[300px] md:top-28 lg:top-32">
|
||||
<div className="from-background via-background/40 absolute inset-x-0 bottom-0 z-10 h-40 bg-gradient-to-t to-transparent" />
|
||||
<div className="from-background via-background/40 absolute inset-x-0 top-0 z-10 h-40 bg-gradient-to-b to-transparent" />
|
||||
<PlusSigns className="text-foreground/[0.05] size-full" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
157
sites/vibes/src/components/sections/why-charter.tsx
Normal file
157
sites/vibes/src/components/sections/why-charter.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { PlusSigns } from "../icons/plus-signs";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: (
|
||||
<>
|
||||
Unparalleled
|
||||
<br />
|
||||
VAR flexibility
|
||||
</>
|
||||
),
|
||||
description: [
|
||||
"VAR is often known for its lengthy processing times. At Charter, we focus on achieving the fastest VAR transfers—often completed within hours.",
|
||||
"Unlike traditional banks and middleware, we connect directly with the Federal Reserve to ensure the fastest, most transparent transfers possible.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/why-charter/1.webp",
|
||||
alt: "Code snippet",
|
||||
type: "fill",
|
||||
},
|
||||
className:
|
||||
"flex flex-col pl-6 py-6 overflow-hidden md:col-span-3 md:flex-row gap-6 md:gap-12",
|
||||
headerClassName: "flex-2 p-0",
|
||||
contentClassName:
|
||||
"relative h-[320px] w-full p-0 self-center overflow-hidden rounded-l-xl border md:flex-1",
|
||||
},
|
||||
{
|
||||
title: "Unparalleled VAR flexibility",
|
||||
description: [
|
||||
"VAR has a reputation for taking too long. At Charter, we optimise for the fastest VAR transfers possible — often in a matter of hours.",
|
||||
"Unlike legacy banks and middleware providers, we have a direct connection to the Federal Reserve to facilitate the quickest transfers.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/why-charter/2.svg",
|
||||
alt: "VAR Process Flow",
|
||||
width: 283,
|
||||
height: 45,
|
||||
},
|
||||
className: "md:col-span-2 flex flex-col justify-center",
|
||||
contentClassName:
|
||||
"flex items-center justify-center p-6 max-md:mt-4 max-md:mb-8",
|
||||
imagePosition: "content",
|
||||
},
|
||||
{
|
||||
title: "Unparalleled VAR flexibility",
|
||||
description: [
|
||||
"VAR has a reputation for taking too long. At Charter, we optimise for the fastest VAR transfers possible — often in a matter of hours.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/why-charter/3.svg",
|
||||
alt: "VAR Process Diagram",
|
||||
width: 283,
|
||||
height: 45,
|
||||
},
|
||||
className: "md:col-span-2",
|
||||
headerClassName: "h-full",
|
||||
imagePosition: "header",
|
||||
},
|
||||
{
|
||||
title: "Unparalleled VAR flexibility",
|
||||
description: [
|
||||
"Unlike traditional banks and middleware, we connect directly with the Federal Reserve to ensure the fastest, most transparent transfers possible.",
|
||||
],
|
||||
image: {
|
||||
src: "/images/homepage/code-snippet.webp",
|
||||
alt: "Code snippet",
|
||||
type: "fill",
|
||||
},
|
||||
className: "overflow-hidden md:col-span-3 ",
|
||||
headerClassName: "",
|
||||
contentClassName:
|
||||
"relative h-[242px] mt-2 p-0 ml-8 w-full md:max-w-[400px] lg:max-w-[500px] overflow-hidden md:mx-auto shadow-xl rounded-t-2xl",
|
||||
},
|
||||
];
|
||||
|
||||
const WhyCharter = () => {
|
||||
return (
|
||||
<section
|
||||
id="why-charter"
|
||||
className="container relative py-16 md:py-28 lg:py-32"
|
||||
>
|
||||
<h3 className="mini-title">WHY CHARTER?</h3>
|
||||
|
||||
<div className="relative z-10 mt-8 grid grid-cols-1 gap-6 md:grid-cols-5">
|
||||
{items.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn("col-span-1 shadow-xl", item.className)}
|
||||
>
|
||||
<CardHeader className={item.headerClassName}>
|
||||
{item.imagePosition === "header" && (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt}
|
||||
width={item.image.width}
|
||||
height={item.image.height}
|
||||
className="flex-1 self-center max-md:mb-8 max-md:mt-4 dark:[filter:brightness(0)_saturate(100%)_invert(100%)]"
|
||||
/>
|
||||
)}
|
||||
<CardTitle className="text-3xl">{item.title}</CardTitle>
|
||||
{item.description.map((desc, i) => (
|
||||
<CardDescription
|
||||
key={i}
|
||||
className="mt-2 text-base font-medium leading-snug"
|
||||
>
|
||||
{desc}
|
||||
</CardDescription>
|
||||
))}
|
||||
</CardHeader>
|
||||
<CardContent className={item.contentClassName}>
|
||||
{item.image.type === "fill" ? (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt}
|
||||
className="size-full object-cover object-left-top"
|
||||
/>
|
||||
) : (
|
||||
item.imagePosition === "content" && (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt}
|
||||
width={item.image.width}
|
||||
height={item.image.height}
|
||||
className="self-center dark:[filter:brightness(0)_saturate(100%)_invert(100%)]"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<>
|
||||
<div className="absolute inset-0 isolate will-change-transform">
|
||||
<div className="bg-primary-gradient/28 absolute top-1/2 size-[700px] -translate-y-1/2 rounded-full blur-[300px]" />
|
||||
<div className="bg-secondary-gradient/16 absolute right-0 top-1/2 size-[700px] -translate-y-1/2 -rotate-12 rounded-full blur-[300px]" />
|
||||
<div className="bg-tertiary-gradient/6 absolute bottom-1/4 right-20 z-[1] h-[500px] w-[800px] -rotate-12 rounded-full blur-[100px] md:bottom-10" />
|
||||
</div>
|
||||
<div className="absolute -inset-x-20 top-0 [mask-image:radial-gradient(circle_at_center,black_0%,black_20%,transparent_85%)]">
|
||||
<PlusSigns className="text-foreground/[0.075] h-full w-full" />
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhyCharter;
|
||||
58
sites/vibes/src/components/ui/accordion.tsx
Normal file
58
sites/vibes/src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="text-muted-foreground size-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
51
sites/vibes/src/components/ui/avatar.tsx
Normal file
51
sites/vibes/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
sites/vibes/src/components/ui/badge.tsx
Normal file
46
sites/vibes/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
sites/vibes/src/components/ui/button.tsx
Normal file
58
sites/vibes/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
83
sites/vibes/src/components/ui/card.tsx
Normal file
83
sites/vibes/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-card text-card-foreground rounded-xl border shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
262
sites/vibes/src/components/ui/carousel.tsx
Normal file
262
sites/vibes/src/components/ui/carousel.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
|
||||
import * as React from 'react';
|
||||
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on('reInit', onSelect);
|
||||
api.on('select', onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = 'Carousel';
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = 'CarouselContent';
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = 'CarouselItem';
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -left-12 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = 'CarouselPrevious';
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -right-12 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = 'CarouselNext';
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
31
sites/vibes/src/components/ui/checkbox.tsx
Normal file
31
sites/vibes/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-xs border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
11
sites/vibes/src/components/ui/collapsible.tsx
Normal file
11
sites/vibes/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
22
sites/vibes/src/components/ui/input.tsx
Normal file
22
sites/vibes/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-border file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring bg-card flex h-11 w-full rounded-md border px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
27
sites/vibes/src/components/ui/label.tsx
Normal file
27
sites/vibes/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
129
sites/vibes/src/components/ui/navigation-menu.tsx
Normal file
129
sites/vibes/src/components/ui/navigation-menu.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-10 flex max-w-max flex-1 items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group flex flex-1 list-none items-center justify-center space-x-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background p-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50',
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDown
|
||||
className="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full md:absolute md:w-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn('absolute top-full left-0 flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow-sm md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] size-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
26
sites/vibes/src/components/ui/separator.tsx
Normal file
26
sites/vibes/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
56
sites/vibes/src/components/ui/tabs.tsx
Normal file
56
sites/vibes/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
22
sites/vibes/src/components/ui/textarea.tsx
Normal file
22
sites/vibes/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'border-border placeholder:text-muted-foreground focus-visible:ring-ring bg-card flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
Loading…
Add table
Add a link
Reference in a new issue