feat: integrate Tailwind CSS with custom theme and animations

Added Tailwind CSS and tw-animate-css imports to globals.css. Introduced a custom dark variant and an extensive @theme inline block with CSS variables for colors, radii, and animations. Included keyframes for shimmer-slide, spin-around, gradient, shine, and background-position-spin to enable consistent theming and dynamic UI effects across the application.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-23 19:57:09 -03:00
1 parent 55c169bd6e
commit 5c1ccbc69a
67 files changed
+8984 -160

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
"use client";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { WordRotate } from "@/components/ui/word-rotate";
import { ThemeToggle } from "./theme-toggle";
import { LanguageSwitcher } from "./language-switcher";
import { MobileMenu } from "./mobile-menu";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import Link from "next/link";
export default function Header() {
const t = useTranslations("header.nav");
const links = [
{ href: "/market", label: t("market") },
{ href: "/sobre", label: t("about") },
{ href: "/precos", label: t("prices") },
];
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/60 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4">
{/* Logo */}
<div className="flex items-center gap-2">
<Link href="/">
<WordRotate
words={["Pagodera", "Tibia Trade"]}
className="text-lg font-semibold tracking-tight hover:text-primary transition-colors"
/>
</Link>
</div>
{/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{links.map(({ href, label }) => (
<NavigationMenuItem key={href}>
<NavigationMenuLink asChild>
<Link
href={href}
className={cn(
navigationMenuTriggerStyle(),
"bg-transparent"
)}
>
{label}
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
{/* Desktop Controls */}
<div className="hidden md:flex items-center gap-2">
<ThemeToggle />
<LanguageSwitcher />
</div>
{/* Mobile Menu */}
<div className="md:hidden">
<MobileMenu links={links} />
</div>
</div>
</header>
);
}
View File
Whitespace-only changes.
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { TibiaItem } from "@/types/item";
import { MagicCard } from "@/components/ui/magic-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PriceDisplay } from "@/components/ui/price-display";
import { useTranslations } from "next-intl";
import {
TrendingUp,
TrendingDown,
Sword,
Shield,
Weight,
Heart,
Star,
Clock,
Coins,
ArrowUp,
ArrowDown,
Minus,
Zap,
Flame,
Snowflake,
Zap as Energy,
Skull,
Cross,
} from "lucide-react";
import { useState } from "react";
import {
getRarityColor,
getRarityBgColor,
getRarityGlow,
} from "@/lib/currency-utils";
interface ItemCardProps {
item: TibiaItem;
}
export function ItemCard({ item }: ItemCardProps) {
const t = useTranslations("home.item");
const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const rarityColors = {
common: "bg-gray-500",
uncommon: "bg-green-500",
rare: "bg-blue-500",
epic: "bg-purple-500",
legendary: "bg-orange-500",
};
const categoryIcons = {
weapon: Sword,
armor: Shield,
helmet: Shield,
legs: Shield,
boots: Shield,
shield: Shield,
amulet: "💎",
ring: "💍",
potion: "🧪",
rune: "📜",
tool: "🔧",
ammunition: "🏹",
other: "📦",
};
const CategoryIcon = categoryIcons[item.category];
// Calcular diferença percentual entre compra e venda
const priceDifference =
((item.price.buy - item.price.sell) / item.price.sell) * 100;
const isHotDeal = priceDifference > 20; // Hot deal se diferença > 20%
// Determinar tendência de preço
const getPriceTrend = () => {
if (item.priceHistory.highest > item.priceHistory.average * 1.1)
return "up";
if (item.priceHistory.lowest < item.priceHistory.average * 0.9)
return "down";
return "stable";
};
const trend = getPriceTrend();
const TrendIcon =
trend === "up" ? ArrowUp : trend === "down" ? ArrowDown : Minus;
const trendColor =
trend === "up"
? "text-green-500"
: trend === "down"
? "text-red-500"
: "text-gray-500";
return (
<div
className="group relative h-full"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<MagicCard className="p-0 overflow-hidden h-full flex flex-col rounded-xl border border-border/50 hover:border-primary/30 transition-all duration-300 hover:scale-[1.02] hover:shadow-xl">
{/* Header com ícone e badge de raridade */}
<div className="relative aspect-square bg-linear-to-br from-background to-muted p-6">
{/* Badge de raridade com glow */}
<Badge
className={`${
rarityColors[item.rarity]
} text-white absolute top-3 left-3 z-20 shadow-lg ${getRarityGlow(
item.rarity
)} ${item.rarity === "legendary" ? "animate-pulse" : ""} ${
item.rarity === "epic" ? "animate-pulse" : ""
}`}
>
{item.rarity}
</Badge>
{/* Botão de favorito */}
<Button
variant="ghost"
size="icon"
className="absolute top-3 right-3 z-20 h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={() => setIsFavorited(!isFavorited)}
>
<Heart
className={`h-4 w-4 transition-colors ${
isFavorited
? "fill-red-500 text-red-500"
: "text-muted-foreground hover:text-red-500"
}`}
/>
</Button>
{/* Hot Deal Badge */}
{isHotDeal && (
<Badge className="absolute top-3 left-1/2 transform -translate-x-1/2 z-20 bg-red-500 text-white shadow-lg animate-pulse">
🔥 Hot Deal
</Badge>
)}
{/* Ícone do item */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? (
CategoryIcon
) : (
<CategoryIcon className="w-10 h-10" />
)}
</div>
</div>
</div>
{/* Conteúdo do card */}
<div className="p-6 space-y-4 flex-1 flex flex-col">
{/* Nome do item */}
<div className="space-y-2">
<h3 className="font-bold text-xl text-foreground leading-tight">
{item.name}
</h3>
{/* Timestamp */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>Atualizado há 5 min</span>
</div>
</div>
{/* Estatísticas agrupadas */}
{item.stats && (
<div className="space-y-2">
<div className="flex flex-wrap gap-3">
{item.stats.attack && (
<div className="flex items-center gap-1.5 text-sm">
<Sword className="h-4 w-4 text-primary" />
<span className="font-medium">
⚔️ {item.stats.attack} ATK
</span>
</div>
)}
{item.stats.defense && (
<div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" />
<span className="font-medium">
🛡️ {item.stats.defense} DEF
</span>
</div>
)}
{item.stats.armor && (
<div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" />
<span className="font-medium">
🛡️ {item.stats.armor} ARM
</span>
</div>
)}
</div>
{/* Proteções elementais */}
{item.protections && (
<div className="flex flex-wrap gap-2">
{item.protections.fire && (
<div className="flex items-center gap-1 text-xs bg-red-500/10 text-red-600 px-2 py-1 rounded">
<Flame className="h-3 w-3" />
<span>🔥 Fire +{item.protections.fire}%</span>
</div>
)}
{item.protections.ice && (
<div className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-600 px-2 py-1 rounded">
<Snowflake className="h-3 w-3" />
<span>❄️ Ice +{item.protections.ice}%</span>
</div>
)}
{item.protections.energy && (
<div className="flex items-center gap-1 text-xs bg-yellow-500/10 text-yellow-600 px-2 py-1 rounded">
<Energy className="h-3 w-3" />
<span>⚡ Energy +{item.protections.energy}%</span>
</div>
)}
</div>
)}
{/* Requisitos */}
{item.requirements && (
<div className="space-y-1">
{item.requirements.level && (
<div className="text-xs text-muted-foreground">
📊 Level {item.requirements.level}+
</div>
)}
{item.requirements.vocation && (
<div className="text-xs text-muted-foreground">
👤 Best for {item.requirements.vocation}
</div>
)}
</div>
)}
{item.stats.weight && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Weight className="h-4 w-4" />
<span>⚖️ {item.stats.weight} oz</span>
</div>
)}
</div>
)}
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* Preços com ícones e formatação melhorada */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="h-3 w-3" />
<span>{t("buyPrice")}</span>
</div>
<PriceDisplay
value={item.price.buy}
className="text-green-600 dark:text-green-400"
size="md"
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="h-3 w-3" />
<span>{t("sellPrice")}</span>
</div>
<PriceDisplay
value={item.price.sell}
className="text-red-600 dark:text-red-400"
size="md"
/>
</div>
</div>
{/* Diferença percentual */}
<div className="text-center">
<span
className={`text-sm font-medium ${
priceDifference > 0 ? "text-green-600" : "text-red-600"
}`}
>
{priceDifference > 0 ? "+" : ""}
{priceDifference.toFixed(1)}% diferença
</span>
</div>
</div>
{/* Divisor visual */}
<div className="border-t border-border/50" />
{/* Histórico de preço com sparkline */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{t("priceHistory")}
</span>
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
<TrendIcon className="h-3 w-3" />
<span className="capitalize">{trend}</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-green-600">
<TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span>
</div>
<div className="font-bold">
🪙 {item.priceHistory.highest.toLocaleString()} gp
</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span>
</div>
<div className="font-bold">
🪙 {item.priceHistory.lowest.toLocaleString()} gp
</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span>
</div>
<div className="font-bold">
🪙 {item.priceHistory.average.toLocaleString()} gp
</div>
</div>
</div>
</div>
{/* Botões de ação */}
<div className="pt-4 space-y-2">
<Button
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
variant="outline"
>
{t("viewDetails")}
</Button>
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
{t("buyNow")}
</Button>
</div>
</div>
</MagicCard>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { Globe } from "lucide-react";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
const languages = [
{ code: "pt", name: "Português", flag: "🇧🇷" },
{ code: "en", name: "English", flag: "🇺🇸" },
];
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("header");
const handleLanguageChange = (newLocale: string) => {
// Remove the current locale from the pathname
const pathWithoutLocale = pathname.replace(`/${locale}`, "") || "/";
// Navigate to the new locale
router.push(`/${newLocale}${pathWithoutLocale}`);
};
const currentLanguage = languages.find((lang) => lang.code === locale);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Globe className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("language")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={locale === language.code ? "bg-accent" : ""}
>
<span className="mr-2">{language.flag}</span>
<span>{language.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+292
View File
@@ -0,0 +1,292 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Search,
Filter,
SortAsc,
SortDesc,
X,
Star,
TrendingUp,
Clock,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface MarketplaceFiltersProps {
onSearch: (query: string) => void;
onFilter: (filters: FilterState) => void;
onSort: (sortBy: string) => void;
totalItems: number;
}
interface FilterState {
rarity: string[];
category: string[];
priceRange: { min: number; max: number };
}
export function MarketplaceFilters({
onSearch,
onFilter,
onSort,
totalItems,
}: MarketplaceFiltersProps) {
const t = useTranslations("home");
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<FilterState>({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
});
const [sortBy, setSortBy] = useState("name");
const rarityOptions = [
{ value: "common", label: "Comum", color: "bg-gray-500" },
{ value: "uncommon", label: "Incomum", color: "bg-green-500" },
{ value: "rare", label: "Raro", color: "bg-blue-500" },
{ value: "epic", label: "Épico", color: "bg-purple-500" },
{ value: "legendary", label: "Lendário", color: "bg-orange-500" },
];
const categoryOptions = [
{ value: "weapon", label: "Arma" },
{ value: "armor", label: "Armadura" },
{ value: "amulet", label: "Amuleto" },
{ value: "ring", label: "Anel" },
{ value: "potion", label: "Poção" },
{ value: "rune", label: "Runa" },
{ value: "other", label: "Outros" },
];
const sortOptions = [
{ value: "name", label: "Nome", icon: SortAsc },
{ value: "price-asc", label: "Preço (Menor)", icon: SortAsc },
{ value: "price-desc", label: "Preço (Maior)", icon: SortDesc },
{ value: "rarity", label: "Raridade", icon: Star },
{ value: "trending", label: "Tendência", icon: TrendingUp },
{ value: "recent", label: "Recente", icon: Clock },
];
const handleSearch = (query: string) => {
setSearchQuery(query);
onSearch(query);
};
const toggleRarityFilter = (rarity: string) => {
const newRarity = activeFilters.rarity.includes(rarity)
? activeFilters.rarity.filter((r) => r !== rarity)
: [...activeFilters.rarity, rarity];
const newFilters = { ...activeFilters, rarity: newRarity };
setActiveFilters(newFilters);
onFilter(newFilters);
};
const toggleCategoryFilter = (category: string) => {
const newCategory = activeFilters.category.includes(category)
? activeFilters.category.filter((c) => c !== category)
: [...activeFilters.category, category];
const newFilters = { ...activeFilters, category: newCategory };
setActiveFilters(newFilters);
onFilter(newFilters);
};
const handleSort = (sortValue: string) => {
setSortBy(sortValue);
onSort(sortValue);
};
const clearFilters = () => {
setActiveFilters({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
});
setSearchQuery("");
onFilter({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
});
onSearch("");
};
const activeFiltersCount =
activeFilters.rarity.length + activeFilters.category.length;
return (
<div className="space-y-6">
{/* Barra de busca */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar itens..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 pr-4"
/>
</div>
{/* Filtros e ordenação */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por raridade */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Raridade
{activeFilters.rarity.length > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilters.rarity.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Filtrar por Raridade</DropdownMenuLabel>
<DropdownMenuSeparator />
{rarityOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => toggleRarityFilter(option.value)}
className="flex items-center gap-2"
>
<div className={`w-3 h-3 rounded-full ${option.color}`} />
<span>{option.label}</span>
{activeFilters.rarity.includes(option.value) && (
<X className="h-3 w-3 ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Filtro por categoria */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Categoria
{activeFilters.category.length > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilters.category.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Filtrar por Categoria</DropdownMenuLabel>
<DropdownMenuSeparator />
{categoryOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => toggleCategoryFilter(option.value)}
className="flex items-center gap-2"
>
<span>{option.label}</span>
{activeFilters.category.includes(option.value) && (
<X className="h-3 w-3 ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Ordenação */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<SortAsc className="h-4 w-4" />
Ordenar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Ordenar por</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onClick={() => handleSort(option.value)}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
{sortBy === option.value && (
<div className="w-2 h-2 bg-primary rounded-full ml-auto" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Limpar filtros */}
{activeFiltersCount > 0 && (
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Limpar Filtros
</Button>
)}
</div>
{/* Badges de filtros ativos */}
{activeFiltersCount > 0 && (
<div className="flex flex-wrap gap-2">
{activeFilters.rarity.map((rarity) => {
const option = rarityOptions.find((opt) => opt.value === rarity);
return (
<Badge
key={rarity}
variant="secondary"
className="gap-1 cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
onClick={() => toggleRarityFilter(rarity)}
>
<div className={`w-2 h-2 rounded-full ${option?.color}`} />
{option?.label}
<X className="h-3 w-3" />
</Badge>
);
})}
{activeFilters.category.map((category) => {
const option = categoryOptions.find(
(opt) => opt.value === category
);
return (
<Badge
key={category}
variant="secondary"
className="gap-1 cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
onClick={() => toggleCategoryFilter(category)}
>
{option?.label}
<X className="h-3 w-3" />
</Badge>
);
})}
</div>
)}
{/* Contador de resultados */}
<div className="text-sm text-muted-foreground">
{totalItems}{" "}
{totalItems === 1 ? "item encontrado" : "itens encontrados"}
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { Menu } from "lucide-react";
import { useState } from "react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import { LanguageSwitcher } from "./language-switcher";
import { useTranslations } from "next-intl";
import Link from "next/link";
interface MobileMenuProps {
links: Array<{ href: string; label: string }>;
}
export function MobileMenu({ links }: MobileMenuProps) {
const [open, setOpen] = useState(false);
const t = useTranslations("header");
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="md:hidden">
<Menu className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("menu")}</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
<div className="flex flex-col space-y-4 mt-4">
{/* Navigation Links */}
<div className="space-y-2">
{links.map(({ href, label }) => (
<Link
key={href}
href={href}
className="block px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => setOpen(false)}
>
{label}
</Link>
))}
</div>
<Separator />
{/* Theme and Language Controls */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("theme.light")}</span>
<ThemeToggle />
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("language")}</span>
<LanguageSwitcher />
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { MarketOffer } from "@/types/item";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PriceDisplay } from "@/components/ui/price-display";
import { useTranslations } from "next-intl";
import {
Clock,
User,
Coins,
TrendingUp,
TrendingDown,
Star,
Heart,
Shield,
Sword,
} from "lucide-react";
import { useState } from "react";
import { getRarityColor, getRarityBgColor } from "@/lib/currency-utils";
interface OfferCardProps {
offer: MarketOffer;
}
export function OfferCard({ offer }: OfferCardProps) {
const t = useTranslations("home.stats");
const tButtons = useTranslations("home.buttons");
const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const formatTimeAgo = (timestamp: Date) => {
const now = new Date();
const diff = now.getTime() - timestamp.getTime();
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return "Agora";
if (minutes < 60) return `há ${minutes} ${t("minutes")}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${t("hours")}`;
const days = Math.floor(hours / 24);
return `há ${days} dias`;
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getCategoryIcon = (category: string) => {
switch (category) {
case "weapon":
return <Sword className="w-6 h-6" />;
case "armor":
return <Shield className="w-6 h-6" />;
default:
return "📦";
}
};
const getRarityColor = (rarity: string) => {
switch (rarity) {
case "common":
return "bg-gray-500";
case "uncommon":
return "bg-green-500";
case "rare":
return "bg-blue-500";
case "epic":
return "bg-purple-500";
case "legendary":
return "bg-orange-500";
default:
return "bg-gray-500";
}
};
const isHotDeal = offer.price < offer.item.priceHistory.average * 0.8;
const isNewOffer =
new Date().getTime() - offer.timestamp.getTime() < 1000 * 60 * 30; // 30 min
return (
<div
className="group relative flex items-center justify-between p-6 border border-border/50 rounded-xl hover:border-primary/30 hover:shadow-lg transition-all duration-300 hover:scale-[1.01] bg-background/50 backdrop-blur-sm"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Badges de status */}
<div className="absolute top-3 left-3 flex gap-2">
{isNewOffer && (
<Badge className="bg-green-500 text-white text-xs animate-pulse">
⭐ {t("status.new")}
</Badge>
)}
{isHotDeal && (
<Badge className="bg-red-500 text-white text-xs animate-bounce">
🔥 {t("status.hotDeal")}
</Badge>
)}
</div>
{/* Botão de favorito */}
<Button
variant="ghost"
size="icon"
className="absolute top-3 right-3 h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setIsFavorited(!isFavorited)}
>
<Heart
className={`h-4 w-4 transition-colors ${
isFavorited
? "fill-red-500 text-red-500"
: "text-muted-foreground hover:text-red-500"
}`}
/>
</Button>
<div className="flex items-center gap-6 flex-1">
{/* Ícone do item com raridade */}
<div className="relative">
<div className="w-16 h-16 bg-muted/50 rounded-xl flex items-center justify-center text-2xl backdrop-blur-sm">
{getCategoryIcon(offer.item.category)}
</div>
<Badge
className={`${getRarityColor(
offer.item.rarity
)} text-white absolute -top-2 -right-2 text-xs shadow-lg`}
>
{offer.item.rarity}
</Badge>
</div>
{/* Informações do item */}
<div className="flex-1 space-y-2">
<div className="space-y-1">
<h3 className="font-bold text-lg text-foreground">
{offer.item.name}
</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Avatar className="w-5 h-5">
<AvatarFallback className="text-xs">
{getInitials(offer.seller)}
</AvatarFallback>
</Avatar>
<span className="font-medium">{offer.seller}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>{formatTimeAgo(offer.timestamp)}</span>
</div>
</div>
</div>
{/* Estatísticas do item */}
{offer.item.stats && (
<div className="flex flex-wrap gap-3 text-sm">
{offer.item.stats.attack && (
<div className="flex items-center gap-1 text-muted-foreground">
<Sword className="h-3 w-3" />
<span>{offer.item.stats.attack} ATK</span>
</div>
)}
{offer.item.stats.defense && (
<div className="flex items-center gap-1 text-muted-foreground">
<Shield className="h-3 w-3" />
<span>{offer.item.stats.defense} DEF</span>
</div>
)}
{offer.item.stats.armor && (
<div className="flex items-center gap-1 text-muted-foreground">
<Shield className="h-3 w-3" />
<span>{offer.item.stats.armor} ARM</span>
</div>
)}
</div>
)}
</div>
{/* Preço e ações */}
<div className="flex items-center gap-6">
<div className="text-right space-y-2">
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Coins className="h-4 w-4" />
<span>Preço</span>
</div>
<PriceDisplay
value={offer.price}
size="lg"
className="text-foreground"
/>
<p className="text-sm text-muted-foreground">
{offer.item.stackable
? `Stack: ${offer.quantity}x`
: `Qtd: ${offer.quantity}`}
{offer.item.stackable && offer.quantity > 1 && (
<span className="block text-xs">
{Math.round(offer.price / offer.quantity).toLocaleString()}{" "}
gp cada
</span>
)}
</p>
</div>
<Badge
variant={offer.type === "buy" ? "default" : "secondary"}
className={`${
offer.type === "buy"
? "bg-green-500 hover:bg-green-600"
: "bg-blue-500 hover:bg-blue-600"
} text-white transition-colors`}
>
{offer.type === "buy" ? "Comprando" : "Vendendo"}
</Badge>
</div>
<div className="space-y-2">
<Button
size="sm"
className={`w-full transition-all duration-200 ${
offer.type === "buy"
? "bg-green-600 hover:bg-green-700"
: "bg-blue-600 hover:bg-blue-700"
} text-white`}
>
{tButtons("negotiate")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full opacity-0 group-hover:opacity-100 transition-opacity"
>
{tButtons("viewProfile")}
</Button>
</div>
</div>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Shield, Coins } from "lucide-react";
export function EscrowBanner() {
const t = useTranslations("home.escrow");
return (
<section className="py-8 bg-gradient-to-r from-green-500/10 via-blue-500/10 to-green-500/10 border-b border-green-500/20">
<div className="container">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center">
<Shield className="w-6 h-6 text-green-500" />
</div>
<div>
<h2 className="text-xl font-bold text-foreground">
{t("title")}
</h2>
<p className="text-muted-foreground">{t("description")}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-muted-foreground">Escrow Balance</p>
<p className="text-lg font-bold text-foreground">🪙 0 gp</p>
</div>
<Button className="bg-green-600 hover:bg-green-700 text-white">
<Coins className="w-4 h-4 mr-2" />
{t("deposit")}
</Button>
</div>
</div>
</div>
</section>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useTranslations } from "next-intl";
import { BlurFade } from "@/components/ui/blur-fade";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { NumberTicker } from "@/components/ui/number-ticker";
import {
Wallet,
Handshake,
CheckCircle,
Shield,
Zap,
BarChart,
Lock,
} from "lucide-react";
export function EscrowExplanation() {
const t = useTranslations("home.escrowExplanation");
const benefits = [
{
icon: Shield,
title: t("benefits.protection.title"),
description: t("benefits.protection.description"),
color: "text-green-500",
},
{
icon: Zap,
title: t("benefits.fast.title"),
description: t("benefits.fast.description"),
color: "text-blue-500",
},
{
icon: BarChart,
title: t("benefits.history.title"),
description: t("benefits.history.description"),
color: "text-purple-500",
},
{
icon: Lock,
title: t("benefits.trusted.title"),
description: t("benefits.trusted.description"),
color: "text-orange-500",
},
];
const faqItems = [
{
question: t("faq.q1"),
answer: t("faq.a1"),
},
{
question: t("faq.q2"),
answer: t("faq.a2"),
},
{
question: t("faq.q3"),
answer: t("faq.a3"),
},
{
question: t("faq.q4"),
answer: t("faq.a4"),
},
{
question: t("faq.q5"),
answer: t("faq.a5"),
},
];
const stats = [
{
value: 208,
suffix: "M",
label: t("stats.volume"),
},
{
value: 8763,
suffix: "",
label: t("stats.transactions"),
},
{
value: 99.8,
suffix: "%",
label: t("stats.satisfaction"),
},
{
value: 2,
suffix: "min",
label: t("stats.avgTime"),
},
];
return (
<section className="py-20 bg-muted/30">
<div className="container">
{/* Título e Subtítulo */}
<BlurFade delay={0.1}>
<h2 className="text-5xl font-bold text-center mb-4">{t("title")}</h2>
<p className="text-xl text-center text-muted-foreground mb-16">
{t("subtitle")}
</p>
</BlurFade>
{/* Cards de Etapas */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
{/* Card 1: Depósito */}
<BlurFade delay={0.2}>
<div className="relative p-8 rounded-2xl border border-border/50 bg-card backdrop-blur-lg hover:border-orange-500/30 hover:scale-105 transition-all duration-300">
<div className="absolute -top-4 -left-4 w-12 h-12 bg-orange-500/80 rounded-full flex items-center justify-center text-2xl font-bold text-white shadow-lg">
1
</div>
<Wallet className="w-16 h-16 text-orange-500 mb-4" />
<h3 className="text-2xl font-bold mb-3">{t("step1.title")}</h3>
<p className="text-muted-foreground mb-4">
{t("step1.description")}
</p>
<div className="bg-muted/50 p-3 rounded-lg border border-border/30">
<code className="text-sm text-foreground/80">
{t("step1.example")}
</code>
</div>
</div>
</BlurFade>
{/* Card 2: Negociação */}
<BlurFade delay={0.3}>
<div className="relative p-8 rounded-2xl border border-border/50 bg-card backdrop-blur-lg hover:border-blue-500/30 hover:scale-105 transition-all duration-300">
<div className="absolute -top-4 -left-4 w-12 h-12 bg-blue-500/80 rounded-full flex items-center justify-center text-2xl font-bold text-white shadow-lg">
2
</div>
<Handshake className="w-16 h-16 text-blue-500 mb-4" />
<h3 className="text-2xl font-bold mb-3">{t("step2.title")}</h3>
<p className="text-muted-foreground mb-4">
{t("step2.description")}
</p>
<div className="bg-muted/50 p-3 rounded-lg border border-border/30">
<code className="text-sm text-foreground/80">
{t("step2.example")}
</code>
</div>
</div>
</BlurFade>
{/* Card 3: Conclusão */}
<BlurFade delay={0.4}>
<div className="relative p-8 rounded-2xl border border-border/50 bg-card backdrop-blur-lg hover:border-green-500/30 hover:scale-105 transition-all duration-300">
<div className="absolute -top-4 -left-4 w-12 h-12 bg-green-500/80 rounded-full flex items-center justify-center text-2xl font-bold text-white shadow-lg">
3
</div>
<CheckCircle className="w-16 h-16 text-green-500 mb-4" />
<h3 className="text-2xl font-bold mb-3">{t("step3.title")}</h3>
<p className="text-muted-foreground mb-4">
{t("step3.description")}
</p>
<div className="bg-muted/50 p-3 rounded-lg border border-border/30">
<code className="text-sm text-foreground/80">
{t("step3.example")}
</code>
</div>
</div>
</BlurFade>
</div>
{/* Grid de Benefícios */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-20">
{benefits.map((benefit, idx) => (
<BlurFade key={idx} delay={0.5 + idx * 0.1}>
<div className="p-6 rounded-xl border border-border/50 bg-card backdrop-blur-sm hover:border-accent/50 hover:shadow-lg transition-all">
<benefit.icon className={`w-12 h-12 mb-4 ${benefit.color}`} />
<h4 className="text-xl font-semibold mb-2">{benefit.title}</h4>
<p className="text-muted-foreground">{benefit.description}</p>
</div>
</BlurFade>
))}
</div>
{/* FAQ */}
<BlurFade delay={0.9}>
<h3 className="text-3xl font-bold text-center mb-8">
{t("faq.title")}
</h3>
<Accordion
type="single"
collapsible
className="max-w-3xl mx-auto mb-20"
>
{faqItems.map((item, idx) => (
<AccordionItem key={idx} value={`item-${idx}`}>
<AccordionTrigger className="text-left text-lg font-semibold">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</BlurFade>
{/* CTA Section */}
<BlurFade delay={1.0}>
<div className="relative p-12 rounded-2xl bg-card border border-border backdrop-blur-sm text-center">
<Badge className="mb-4 bg-accent/20 text-accent-foreground">
✨ Primeiras 3 transações sem taxa!
</Badge>
<h3 className="text-3xl font-bold mb-6">{t("cta.title")}</h3>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" className="bg-primary hover:bg-primary/90">
{t("cta.createOffer")}
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
size="lg"
variant="outline"
className="border-border/50"
>
{t("cta.watchVideo")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
<p className="text-muted-foreground">
Vídeo explicativo aqui
</p>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</BlurFade>
{/* Barra de Estatísticas */}
<div className="mt-16 grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
{stats.map((stat, idx) => (
<BlurFade key={idx} delay={1.1 + idx * 0.1}>
<div>
<p className="text-3xl font-bold text-accent mb-2">
<NumberTicker value={stat.value} />
{stat.suffix}
</p>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</div>
</BlurFade>
))}
</div>
</div>
</section>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { ItemCard } from "@/components/item-card";
import { MarketplaceFilters } from "@/components/marketplace-filters";
import { featuredItems } from "@/lib/mock-data";
import { BlurFade } from "@/components/ui/blur-fade";
import { Skeleton } from "@/components/ui/skeleton";
export function FeaturedItems() {
const t = useTranslations("home.featured");
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<{
rarity: string[];
category: string[];
priceRange: { min: number; max: number };
}>({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
});
const [sortBy, setSortBy] = useState("name");
const [isLoading, setIsLoading] = useState(false);
// Filtrar e ordenar itens
const filteredItems = useMemo(() => {
let filtered = [...featuredItems];
// Filtro por busca
if (searchQuery) {
filtered = filtered.filter((item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// Filtro por raridade
if (filters.rarity.length > 0) {
filtered = filtered.filter((item) =>
filters.rarity.includes(item.rarity)
);
}
// Filtro por categoria
if (filters.category.length > 0) {
filtered = filtered.filter((item) =>
filters.category.includes(item.category)
);
}
// Filtro por faixa de preço
filtered = filtered.filter(
(item) =>
item.price.buy >= filters.priceRange.min &&
item.price.buy <= filters.priceRange.max
);
// Ordenação
filtered.sort((a, b) => {
switch (sortBy) {
case "name":
return a.name.localeCompare(b.name);
case "price-asc":
return a.price.buy - b.price.buy;
case "price-desc":
return b.price.buy - a.price.buy;
case "rarity":
const rarityOrder = {
common: 0,
uncommon: 1,
rare: 2,
epic: 3,
legendary: 4,
};
return rarityOrder[a.rarity] - rarityOrder[b.rarity];
case "trending":
return b.priceHistory.highest - a.priceHistory.highest;
case "recent":
// Simular ordenação por data (mais recente primeiro)
return Math.random() - 0.5;
default:
return 0;
}
});
return filtered;
}, [searchQuery, filters, sortBy]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleFilter = (newFilters: any) => {
setFilters(newFilters);
};
const handleSort = (sortValue: string) => {
setSortBy(sortValue);
};
// Skeleton para loading
const LoadingSkeleton = () => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="space-y-4">
<Skeleton className="h-48 w-full rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
))}
</div>
);
return (
<section className="py-20 bg-muted/30">
<div className="container">
<BlurFade delay={0.2}>
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4">{t("title")}</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Descubra os melhores itens do servidor com preços atualizados em
tempo real
</p>
</div>
</BlurFade>
{/* Filtros e busca */}
<div className="mb-12">
<MarketplaceFilters
onSearch={handleSearch}
onFilter={handleFilter}
onSort={handleSort}
totalItems={filteredItems.length}
/>
</div>
{/* Grid de itens */}
{isLoading ? (
<LoadingSkeleton />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredItems.map((item, index) => (
<BlurFade key={item.id} delay={0.2 + index * 0.05}>
<ItemCard item={item} />
</BlurFade>
))}
</div>
)}
{/* Mensagem quando não há resultados */}
{filteredItems.length === 0 && !isLoading && (
<div className="text-center py-12">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-xl font-semibold mb-2">
Nenhum item encontrado
</h3>
<p className="text-muted-foreground mb-4">
Tente ajustar os filtros ou fazer uma nova busca
</p>
<button
onClick={() => {
setSearchQuery("");
setFilters({
rarity: [],
category: [],
priceRange: { min: 0, max: 100000 },
});
setSortBy("name");
}}
className="text-primary hover:underline"
>
Limpar todos os filtros
</button>
</div>
)}
</div>
</section>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text";
import { ShimmerButton } from "@/components/ui/shimmer-button";
import { BlurFade } from "@/components/ui/blur-fade";
import Link from "next/link";
export function HeroSection() {
const t = useTranslations("home.hero");
return (
<section className="relative min-h-[600px] flex items-center justify-center overflow-hidden bg-gradient-to-br from-background to-muted/20 px-4">
<div className="w-full max-w-4xl mx-auto flex flex-col items-center justify-center text-center space-y-8">
<BlurFade delay={0.2}>
<AnimatedGradientText className="text-5xl md:text-7xl font-bold">
{t("title")}
</AnimatedGradientText>
</BlurFade>
<BlurFade delay={0.4}>
<p className="text-xl md:text-2xl text-muted-foreground max-w-2xl mx-auto">
{t("subtitle")}
</p>
</BlurFade>
<BlurFade delay={0.6}>
<div className="flex justify-center">
<Link href="/market">
<ShimmerButton className="px-8 py-6 text-lg block">
{t("cta")}
</ShimmerButton>
</Link>
</div>
</BlurFade>
</div>
</section>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useTranslations } from "next-intl";
import { BentoGrid, BentoCard } from "@/components/ui/bento-grid";
import { ShoppingBag, Coins, Shield } from "lucide-react";
export function HowItWorks() {
const t = useTranslations("home.howItWorks");
const features = [
{
name: t("buy"),
description: "Encontre os melhores itens com preços justos",
className: "md:col-span-1",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-green-500/10 via-green-500/5 to-transparent" />
),
Icon: ShoppingBag,
href: "/market",
cta: "Ver Mercado",
},
{
name: t("sell"),
description: "Venda seu loot e ganhe gold rapidamente",
className: "md:col-span-1",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-amber-500/10 via-amber-500/5 to-transparent" />
),
Icon: Coins,
href: "/market",
cta: "Vender Agora",
},
{
name: t("trade"),
description: "Sistema seguro de escrow protege suas transações",
className: "md:col-span-2",
background: (
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-blue-500/5 to-transparent" />
),
Icon: Shield,
href: "/market",
cta: "Negociar",
},
];
return (
<section className="py-20 bg-background">
<div className="container">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4">{t("title")}</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Descubra como funciona nossa plataforma de trading segura e
eficiente
</p>
</div>
<BentoGrid>
{features.map((feature, idx) => (
<BentoCard key={idx} {...feature} />
))}
</BentoGrid>
</div>
</section>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useTranslations } from "next-intl";
import { AnimatedList } from "@/components/ui/animated-list";
import { OfferCard } from "@/components/offer-card";
import { buyOffers, sellOffers } from "@/lib/mock-data";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export function MarketOffers() {
const t = useTranslations("home.offers");
return (
<section className="py-20 bg-background">
<div className="container">
<h2 className="text-4xl font-bold text-center mb-12">
Ofertas do Mercado
</h2>
<Tabs defaultValue="buying" className="w-full">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2">
<TabsTrigger value="buying">{t("buying")}</TabsTrigger>
<TabsTrigger value="selling">{t("selling")}</TabsTrigger>
</TabsList>
<TabsContent value="buying" className="mt-8">
<AnimatedList>
{buyOffers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</AnimatedList>
</TabsContent>
<TabsContent value="selling" className="mt-8">
<AnimatedList>
{sellOffers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</AnimatedList>
</TabsContent>
</Tabs>
</div>
</section>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useTranslations } from "next-intl";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Badge } from "@/components/ui/badge";
import {
ShoppingCart,
TrendingUp,
Users,
DollarSign,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { AreaChart, Area, ResponsiveContainer } from "recharts";
import { buyOffers, sellOffers } from "@/lib/mock-data";
interface StatData {
icon: typeof ShoppingCart;
value: number;
label: string;
change: number; // percentual
trend: "up" | "down";
sparklineData: number[]; // 7 pontos
iconColor: string;
iconBg: string;
description: string;
lastUpdated: string;
}
// Componente Mini Sparkline
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
const chartData = data.map((value, index) => ({
value,
index,
}));
return (
<div className="h-8 w-full min-h-[32px] min-w-[120px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 2, right: 2, left: 2, bottom: 2 }}
width={120}
height={32}
>
<defs>
<linearGradient
id={`gradient-${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill={`url(#gradient-${color})`}
dot={false}
activeDot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
// Componente StatCard individual
function StatCard({ stat }: { stat: StatData }) {
const TrendIcon = stat.trend === "up" ? ArrowUp : ArrowDown;
const isPositive = stat.trend === "up";
return (
<div className="group relative p-8 text-center rounded-xl border border-border/50 bg-linear-to-br from-card via-card/95 to-muted/30 backdrop-blur-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-2xl hover:border-accent/50 hover:shadow-accent/20">
{/* Ícone com círculo colorido */}
<div
className={`w-12 h-12 mx-auto mb-4 rounded-full ${stat.iconBg} flex items-center justify-center group-hover:animate-pulse`}
>
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} />
</div>
{/* Número principal */}
<div className="mb-2">
<p className="text-6xl font-extrabold font-mono bg-linear-to-br from-foreground to-foreground/70 bg-clip-text">
<NumberTicker value={stat.value} />
</p>
</div>
{/* Badge de variação */}
<div className="mb-3">
<Badge
variant={isPositive ? "default" : "destructive"}
className={`${
isPositive
? "bg-green-500/10 text-green-600 border-green-500/20"
: "bg-red-500/10 text-red-600 border-red-500/20"
} gap-1`}
>
<TrendIcon className="w-3 h-3" />
{stat.change}%
</Badge>
</div>
{/* Label e descrição */}
<div className="mb-4">
<p className="text-lg font-semibold text-foreground">{stat.label}</p>
<p className="text-sm text-muted-foreground">{stat.description}</p>
</div>
{/* Mini Sparkline */}
<div className="mb-3">
<MiniSparkline
data={stat.sparklineData}
color={stat.iconColor.replace("text-", "").replace("-500", "")}
/>
</div>
{/* Timestamp */}
<p className="text-xs text-muted-foreground">{stat.lastUpdated}</p>
</div>
);
}
export function MarketStats() {
const t = useTranslations("home.stats");
// Calcular volume total baseado nos dados mock
const calculateTotalVolume = () => {
const buyVolume = buyOffers.reduce(
(sum, offer) => sum + offer.price * offer.quantity,
0
);
const sellVolume = sellOffers.reduce(
(sum, offer) => sum + offer.price * offer.quantity,
0
);
return Math.round((buyVolume + sellVolume) / 1000); // Dividir por 1000 para número mais legível
};
const stats: StatData[] = [
{
icon: ShoppingCart,
value: 1542,
label: t("activeOffers"),
change: 8.5,
trend: "up",
sparklineData: [1200, 1300, 1400, 1350, 1450, 1500, 1542],
iconColor: "text-orange-500",
iconBg: "bg-orange-500/10",
description: "esta semana",
lastUpdated: `${t("updated")} 5 ${t("minutes")}`,
},
{
icon: TrendingUp,
value: 8763,
label: t("itemsSold"),
change: 15.3,
trend: "up",
sparklineData: [7200, 7500, 7800, 8000, 8200, 8500, 8763],
iconColor: "text-green-500",
iconBg: "bg-green-500/10",
description: `${t("averagePerDay")}: 292 ${t("salesPerDay")}`,
lastUpdated: `${t("updated")} 5 ${t("minutes")}`,
},
{
icon: Users,
value: 3421,
label: t("users"),
change: 22.1,
trend: "up",
sparklineData: [3100, 3200, 3250, 3300, 3350, 3400, 3421],
iconColor: "text-blue-500",
iconBg: "bg-blue-500/10",
description: `${t("onlineNow")}: 847 ${t("traders")}`,
lastUpdated: `${t("updated")} 2 ${t("minutes")}`,
},
{
icon: DollarSign,
value: 208,
label: t("totalVolume"),
change: 18.7,
trend: "up",
sparklineData: [180, 200, 220, 240, 260, 280, 300],
iconColor: "text-purple-500",
iconBg: "bg-purple-500/10",
description: "em gold pieces",
lastUpdated: `${t("today")}: 12.5M gp`,
},
];
return (
<section className="py-20 bg-muted/30">
<div className="container">
<h2 className="text-4xl font-bold text-center mb-12">{t("title")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<StatCard key={index} stat={stat} />
))}
</div>
</div>
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ComponentProps } from "react";
type ThemeProviderProps = ComponentProps<typeof NextThemesProvider>;
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
const t = useTranslations("header.theme");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>{t("light")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>{t("dark")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span>{t("system")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+57
View File
@@ -0,0 +1,57 @@
"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 border-border", 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 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-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="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+38
View File
@@ -0,0 +1,38 @@
import { ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
export interface AnimatedGradientTextProps
extends ComponentPropsWithoutRef<"div"> {
speed?: number
colorFrom?: string
colorTo?: string
}
export function AnimatedGradientText({
children,
className,
speed = 1,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
...props
}: AnimatedGradientTextProps) {
return (
<span
style={
{
"--bg-size": `${speed * 300}%`,
"--color-from": colorFrom,
"--color-to": colorTo,
} as React.CSSProperties
}
className={cn(
`animate-gradient inline bg-gradient-to-r from-[var(--color-from)] via-[var(--color-to)] to-[var(--color-from)] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
className
)}
{...props}
>
{children}
</span>
)
}
+73
View File
@@ -0,0 +1,73 @@
"use client"
import React, {
ComponentPropsWithoutRef,
useEffect,
useMemo,
useState,
} from "react"
import { AnimatePresence, motion, MotionProps } from "motion/react"
import { cn } from "@/lib/utils"
export function AnimatedListItem({ children }: { children: React.ReactNode }) {
const animations: MotionProps = {
initial: { scale: 0, opacity: 0 },
animate: { scale: 1, opacity: 1, originY: 0 },
exit: { scale: 0, opacity: 0 },
transition: { type: "spring", stiffness: 350, damping: 40 },
}
return (
<motion.div {...animations} layout className="mx-auto w-full">
{children}
</motion.div>
)
}
export interface AnimatedListProps extends ComponentPropsWithoutRef<"div"> {
children: React.ReactNode
delay?: number
}
export const AnimatedList = React.memo(
({ children, className, delay = 1000, ...props }: AnimatedListProps) => {
const [index, setIndex] = useState(0)
const childrenArray = useMemo(
() => React.Children.toArray(children),
[children]
)
useEffect(() => {
if (index < childrenArray.length - 1) {
const timeout = setTimeout(() => {
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length)
}, delay)
return () => clearTimeout(timeout)
}
}, [index, delay, childrenArray.length])
const itemsToShow = useMemo(() => {
const result = childrenArray.slice(0, index + 1).reverse()
return result
}, [index, childrenArray])
return (
<div
className={cn(`flex flex-col items-center gap-4`, className)}
{...props}
>
<AnimatePresence>
{itemsToShow.map((item) => (
<AnimatedListItem key={(item as React.ReactElement).key}>
{item}
</AnimatedListItem>
))}
</AnimatePresence>
</div>
)
}
)
AnimatedList.displayName = "AnimatedList"
+53
View File
@@ -0,0 +1,53 @@
"use client"
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
View 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-full 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 dark:bg-destructive/60",
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 }
+96
View File
@@ -0,0 +1,96 @@
import { ComponentPropsWithoutRef, ReactNode } from "react";
import { ArrowRightIcon } from "@radix-ui/react-icons";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface BentoGridProps extends ComponentPropsWithoutRef<"div"> {
children: ReactNode;
className?: string;
}
interface BentoCardProps extends ComponentPropsWithoutRef<"div"> {
name: string;
className: string;
background: ReactNode;
Icon: React.ElementType;
description: string;
href: string;
cta: string;
}
const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
return (
<div
className={cn(
"grid w-full gap-6",
"grid-cols-1 md:grid-cols-3",
"auto-rows-[minmax(280px,auto)]",
className
)}
style={{
gridTemplateColumns: "1fr 2fr",
gridTemplateRows: "auto auto",
}}
{...props}
>
{children}
</div>
);
};
const BentoCard = ({
name,
className,
background,
Icon,
description,
href,
cta,
...props
}: BentoCardProps) => (
<div
key={name}
className={cn(
"group relative flex flex-col justify-between overflow-hidden rounded-xl",
"transition-all duration-300 hover:scale-[1.02] hover:shadow-lg",
// light styles
"bg-background border border-border/50",
"shadow-sm hover:shadow-xl",
// dark styles
"dark:bg-background dark:border-border/50",
"dark:shadow-sm dark:hover:shadow-xl",
className
)}
{...props}
>
<div className="relative">{background}</div>
<div className="p-6 flex flex-col justify-between h-full">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
<h3 className="text-xl font-semibold text-foreground">{name}</h3>
</div>
<p className="text-muted-foreground leading-relaxed">{description}</p>
</div>
<div className="mt-6">
<Button
variant="outline"
asChild
size="sm"
className="w-full justify-between group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
>
<a href={href}>
{cta}
<ArrowRightIcon className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</a>
</Button>
</div>
</div>
</div>
);
export { BentoCard, BentoGrid };
+81
View File
@@ -0,0 +1,81 @@
"use client"
import { useRef } from "react"
import {
AnimatePresence,
motion,
MotionProps,
useInView,
UseInViewOptions,
Variants,
} from "motion/react"
type MarginType = UseInViewOptions["margin"]
interface BlurFadeProps extends MotionProps {
children: React.ReactNode
className?: string
variant?: {
hidden: { y: number }
visible: { y: number }
}
duration?: number
delay?: number
offset?: number
direction?: "up" | "down" | "left" | "right"
inView?: boolean
inViewMargin?: MarginType
blur?: string
}
export function BlurFade({
children,
className,
variant,
duration = 0.4,
delay = 0,
offset = 6,
direction = "down",
inView = false,
inViewMargin = "-50px",
blur = "6px",
...props
}: BlurFadeProps) {
const ref = useRef(null)
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
const isInView = !inView || inViewResult
const defaultVariants: Variants = {
hidden: {
[direction === "left" || direction === "right" ? "x" : "y"]:
direction === "right" || direction === "down" ? -offset : offset,
opacity: 0,
filter: `blur(${blur})`,
},
visible: {
[direction === "left" || direction === "right" ? "x" : "y"]: 0,
opacity: 1,
filter: `blur(0px)`,
},
}
const combinedVariants = variant || defaultVariants
return (
<AnimatePresence>
<motion.div
ref={ref}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
exit="hidden"
variants={combinedVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: "easeOut",
}}
className={className}
{...props}
>
{children}
</motion.div>
</AnimatePresence>
)
}
+60
View File
@@ -0,0 +1,60 @@
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-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+158
View File
@@ -0,0 +1,158 @@
"use client"
import React, { useEffect, useId, useRef, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
/**
* DotPattern Component Props
*
* @param {number} [width=16] - The horizontal spacing between dots
* @param {number} [height=16] - The vertical spacing between dots
* @param {number} [x=0] - The x-offset of the entire pattern
* @param {number} [y=0] - The y-offset of the entire pattern
* @param {number} [cx=1] - The x-offset of individual dots
* @param {number} [cy=1] - The y-offset of individual dots
* @param {number} [cr=1] - The radius of each dot
* @param {string} [className] - Additional CSS classes to apply to the SVG container
* @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
*/
interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
width?: number
height?: number
x?: number
y?: number
cx?: number
cy?: number
cr?: number
className?: string
glow?: boolean
[key: string]: unknown
}
/**
* DotPattern Component
*
* A React component that creates an animated or static dot pattern background using SVG.
* The pattern automatically adjusts to fill its container and can optionally display glowing dots.
*
* @component
*
* @see DotPatternProps for the props interface.
*
* @example
* // Basic usage
* <DotPattern />
*
* // With glowing effect and custom spacing
* <DotPattern
* width={20}
* height={20}
* glow={true}
* className="opacity-50"
* />
*
* @notes
* - The component is client-side only ("use client")
* - Automatically responds to container size changes
* - When glow is enabled, dots will animate with random delays and durations
* - Uses Motion for animations
* - Dots color can be controlled via the text color utility classes
*/
export function DotPattern({
width = 16,
height = 16,
x = 0,
y = 0,
cx = 1,
cy = 1,
cr = 1,
className,
glow = false,
...props
}: DotPatternProps) {
const id = useId()
const containerRef = useRef<SVGSVGElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect()
setDimensions({ width, height })
}
}
updateDimensions()
window.addEventListener("resize", updateDimensions)
return () => window.removeEventListener("resize", updateDimensions)
}, [])
const dots = Array.from(
{
length:
Math.ceil(dimensions.width / width) *
Math.ceil(dimensions.height / height),
},
(_, i) => {
const col = i % Math.ceil(dimensions.width / width)
const row = Math.floor(i / Math.ceil(dimensions.width / width))
return {
x: col * width + cx,
y: row * height + cy,
delay: Math.random() * 5,
duration: Math.random() * 3 + 2,
}
}
)
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full text-neutral-400/80",
className
)}
{...props}
>
<defs>
<radialGradient id={`${id}-gradient`}>
<stop offset="0%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
</defs>
{dots.map((dot, index) => (
<motion.circle
key={`${dot.x}-${dot.y}`}
cx={dot.x}
cy={dot.y}
r={cr}
fill={glow ? `url(#${id}-gradient)` : "currentColor"}
initial={glow ? { opacity: 0.4, scale: 1 } : {}}
animate={
glow
? {
opacity: [0.4, 1, 0.4],
scale: [1, 1.5, 1],
}
: {}
}
transition={
glow
? {
duration: dot.duration,
repeat: Infinity,
repeatType: "reverse",
delay: dot.delay,
ease: "easeInOut",
}
: {}
}
/>
))}
</svg>
)
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+128
View File
@@ -0,0 +1,128 @@
"use client"
import { useEffect, useRef } from "react"
import createGlobe, { COBEOptions } from "cobe"
import { useMotionValue, useSpring } from "motion/react"
import { cn } from "@/lib/utils"
const MOVEMENT_DAMPING = 1400
const GLOBE_CONFIG: COBEOptions = {
width: 800,
height: 800,
onRender: () => {},
devicePixelRatio: 2,
phi: 0,
theta: 0.3,
dark: 0,
diffuse: 0.4,
mapSamples: 16000,
mapBrightness: 1.2,
baseColor: [1, 1, 1],
markerColor: [251 / 255, 100 / 255, 21 / 255],
glowColor: [1, 1, 1],
markers: [
{ location: [14.5995, 120.9842], size: 0.03 },
{ location: [19.076, 72.8777], size: 0.1 },
{ location: [23.8103, 90.4125], size: 0.05 },
{ location: [30.0444, 31.2357], size: 0.07 },
{ location: [39.9042, 116.4074], size: 0.08 },
{ location: [-23.5505, -46.6333], size: 0.1 },
{ location: [19.4326, -99.1332], size: 0.1 },
{ location: [40.7128, -74.006], size: 0.1 },
{ location: [34.6937, 135.5022], size: 0.05 },
{ location: [41.0082, 28.9784], size: 0.06 },
],
}
export function Globe({
className,
config = GLOBE_CONFIG,
}: {
className?: string
config?: COBEOptions
}) {
let phi = 0
let width = 0
const canvasRef = useRef<HTMLCanvasElement>(null)
const pointerInteracting = useRef<number | null>(null)
const pointerInteractionMovement = useRef(0)
const r = useMotionValue(0)
const rs = useSpring(r, {
mass: 1,
damping: 30,
stiffness: 100,
})
const updatePointerInteraction = (value: number | null) => {
pointerInteracting.current = value
if (canvasRef.current) {
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab"
}
}
const updateMovement = (clientX: number) => {
if (pointerInteracting.current !== null) {
const delta = clientX - pointerInteracting.current
pointerInteractionMovement.current = delta
r.set(r.get() + delta / MOVEMENT_DAMPING)
}
}
useEffect(() => {
const onResize = () => {
if (canvasRef.current) {
width = canvasRef.current.offsetWidth
}
}
window.addEventListener("resize", onResize)
onResize()
const globe = createGlobe(canvasRef.current!, {
...config,
width: width * 2,
height: width * 2,
onRender: (state) => {
if (!pointerInteracting.current) phi += 0.005
state.phi = phi + rs.get()
state.width = width * 2
state.height = width * 2
},
})
setTimeout(() => (canvasRef.current!.style.opacity = "1"), 0)
return () => {
globe.destroy()
window.removeEventListener("resize", onResize)
}
}, [rs, config])
return (
<div
className={cn(
"absolute inset-0 mx-auto aspect-[1/1] w-full max-w-[600px]",
className
)}
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX
updatePointerInteraction(e.clientX)
}}
onPointerUp={() => updatePointerInteraction(null)}
onPointerOut={() => updatePointerInteraction(null)}
onMouseMove={(e) => updateMovement(e.clientX)}
onTouchMove={(e) =>
e.touches[0] && updateMovement(e.touches[0].clientX)
}
/>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }
+103
View File
@@ -0,0 +1,103 @@
"use client"
import React, { useCallback, useEffect } from "react"
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
import { cn } from "@/lib/utils"
interface MagicCardProps {
children?: React.ReactNode
className?: string
gradientSize?: number
gradientColor?: string
gradientOpacity?: number
gradientFrom?: string
gradientTo?: string
}
export function MagicCard({
children,
className,
gradientSize = 200,
gradientColor = "#262626",
gradientOpacity = 0.8,
gradientFrom = "#9E7AFF",
gradientTo = "#FE8BBB",
}: MagicCardProps) {
const mouseX = useMotionValue(-gradientSize)
const mouseY = useMotionValue(-gradientSize)
const reset = useCallback(() => {
mouseX.set(-gradientSize)
mouseY.set(-gradientSize)
}, [gradientSize, mouseX, mouseY])
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
mouseX.set(e.clientX - rect.left)
mouseY.set(e.clientY - rect.top)
},
[mouseX, mouseY]
)
useEffect(() => {
reset()
}, [reset])
useEffect(() => {
const handleGlobalPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) {
reset()
}
}
const handleVisibility = () => {
if (document.visibilityState !== "visible") {
reset()
}
}
window.addEventListener("pointerout", handleGlobalPointerOut)
window.addEventListener("blur", reset)
document.addEventListener("visibilitychange", handleVisibility)
return () => {
window.removeEventListener("pointerout", handleGlobalPointerOut)
window.removeEventListener("blur", reset)
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [reset])
return (
<div
className={cn("group relative rounded-[inherit]", className)}
onPointerMove={handlePointerMove}
onPointerLeave={reset}
onPointerEnter={reset}
>
<motion.div
className="bg-border pointer-events-none absolute inset-0 rounded-[inherit] duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
var(--border) 100%
)
`,
}}
/>
<div className="bg-background absolute inset-px rounded-[inherit]" />
<motion.div
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
`,
opacity: gradientOpacity,
}}
/>
<div className="relative">{children}</div>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { useCallback, useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
const morphTime = 1.5
const cooldownTime = 0.5
const useMorphingText = (texts: string[]) => {
const textIndexRef = useRef(0)
const morphRef = useRef(0)
const cooldownRef = useRef(0)
const timeRef = useRef(new Date())
const text1Ref = useRef<HTMLSpanElement>(null)
const text2Ref = useRef<HTMLSpanElement>(null)
const setStyles = useCallback(
(fraction: number) => {
const [current1, current2] = [text1Ref.current, text2Ref.current]
if (!current1 || !current2) return
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`
const invertedFraction = 1 - fraction
current1.style.filter = `blur(${Math.min(
8 / invertedFraction - 8,
100
)}px)`
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`
current1.textContent = texts[textIndexRef.current % texts.length]
current2.textContent = texts[(textIndexRef.current + 1) % texts.length]
},
[texts]
)
const doMorph = useCallback(() => {
morphRef.current -= cooldownRef.current
cooldownRef.current = 0
let fraction = morphRef.current / morphTime
if (fraction > 1) {
cooldownRef.current = cooldownTime
fraction = 1
}
setStyles(fraction)
if (fraction === 1) {
textIndexRef.current++
}
}, [setStyles])
const doCooldown = useCallback(() => {
morphRef.current = 0
const [current1, current2] = [text1Ref.current, text2Ref.current]
if (current1 && current2) {
current2.style.filter = "none"
current2.style.opacity = "100%"
current1.style.filter = "none"
current1.style.opacity = "0%"
}
}, [])
useEffect(() => {
let animationFrameId: number
const animate = () => {
animationFrameId = requestAnimationFrame(animate)
const newTime = new Date()
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000
timeRef.current = newTime
cooldownRef.current -= dt
if (cooldownRef.current <= 0) doMorph()
else doCooldown()
}
animate()
return () => {
cancelAnimationFrame(animationFrameId)
}
}, [doMorph, doCooldown])
return { text1Ref, text2Ref }
}
interface MorphingTextProps {
className?: string
texts: string[]
}
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
const { text1Ref, text2Ref } = useMorphingText(texts)
return (
<>
<span
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
ref={text1Ref}
/>
<span
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
ref={text2Ref}
/>
</>
)
}
const SvgFilters: React.FC = () => (
<svg
id="filters"
className="fixed h-0 w-0"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<filter id="threshold">
<feColorMatrix
in="SourceGraphic"
type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 255 -140"
/>
</filter>
</defs>
</svg>
)
export const MorphingText: React.FC<MorphingTextProps> = ({
texts,
className,
}) => (
<div
className={cn(
"relative mx-auto h-16 w-full max-w-screen-md text-center font-sans text-[40pt] leading-none font-bold [filter:url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
className
)}
>
<Texts texts={texts} />
<SvgFilters />
</div>
)
+168
View File
@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
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 p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-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 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
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%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+148
View File
@@ -0,0 +1,148 @@
"use client"
import {
CSSProperties,
ReactElement,
ReactNode,
useEffect,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
interface NeonColorsProps {
firstColor: string
secondColor: string
}
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* @default <div />
* @type ReactElement
* @description
* The component to be rendered as the card
* */
as?: ReactElement
/**
* @default ""
* @type string
* @description
* The className of the card
*/
className?: string
/**
* @default ""
* @type ReactNode
* @description
* The children of the card
* */
children?: ReactNode
/**
* @default 5
* @type number
* @description
* The size of the border in pixels
* */
borderSize?: number
/**
* @default 20
* @type number
* @description
* The size of the radius in pixels
* */
borderRadius?: number
/**
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
* @type string
* @description
* The colors of the neon gradient
* */
neonColors?: NeonColorsProps
}
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
className,
children,
borderSize = 2,
borderRadius = 20,
neonColors = {
firstColor: "#ff00aa",
secondColor: "#00FFF1",
},
...props
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
}
}
updateDimensions()
window.addEventListener("resize", updateDimensions)
return () => {
window.removeEventListener("resize", updateDimensions)
}
}, [])
useEffect(() => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
}
}, [children])
return (
<div
ref={containerRef}
style={
{
"--border-size": `${borderSize}px`,
"--border-radius": `${borderRadius}px`,
"--neon-first-color": neonColors.firstColor,
"--neon-second-color": neonColors.secondColor,
"--card-width": `${dimensions.width}px`,
"--card-height": `${dimensions.height}px`,
"--card-content-radius": `${borderRadius - borderSize}px`,
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
"--after-blur": `${dimensions.width / 3}px`,
} as CSSProperties
}
className={cn(
"relative z-10 size-full rounded-[var(--border-radius)]",
className
)}
{...props}
>
<div
className={cn(
"relative size-full min-h-[inherit] rounded-[var(--card-content-radius)] bg-gray-100 p-6",
"before:absolute before:-top-[var(--border-size)] before:-left-[var(--border-size)] before:-z-10 before:block",
"before:h-[var(--pseudo-element-height)] before:w-[var(--pseudo-element-width)] before:rounded-[var(--border-radius)] before:content-['']",
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-[length:100%_200%]",
"before:animate-background-position-spin",
"after:absolute after:-top-[var(--border-size)] after:-left-[var(--border-size)] after:-z-10 after:block",
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
"after:animate-background-position-spin",
"dark:bg-neutral-900",
"break-words"
)}
>
{children}
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
"use client"
import { ComponentPropsWithoutRef, useEffect, useRef } from "react"
import { useInView, useMotionValue, useSpring } from "motion/react"
import { cn } from "@/lib/utils"
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
value: number
startValue?: number
direction?: "up" | "down"
delay?: number
decimalPlaces?: number
}
export function NumberTicker({
value,
startValue = 0,
direction = "up",
delay = 0,
className,
decimalPlaces = 0,
...props
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null)
const motionValue = useMotionValue(direction === "down" ? value : startValue)
const springValue = useSpring(motionValue, {
damping: 60,
stiffness: 100,
})
const isInView = useInView(ref, { once: true, margin: "0px" })
useEffect(() => {
if (isInView) {
const timer = setTimeout(() => {
motionValue.set(direction === "down" ? startValue : value)
}, delay * 1000)
return () => clearTimeout(timer)
}
}, [motionValue, isInView, delay, value, direction, startValue])
useEffect(
() =>
springValue.on("change", (latest) => {
if (ref.current) {
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)))
}
}),
[springValue, decimalPlaces]
)
return (
<span
ref={ref}
className={cn(
"inline-block tracking-wider text-black tabular-nums dark:text-white",
className
)}
{...props}
>
{startValue}
</span>
)
}
+314
View File
@@ -0,0 +1,314 @@
"use client"
import React, {
ComponentPropsWithoutRef,
useEffect,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
interface MousePosition {
x: number
y: number
}
function MousePosition(): MousePosition {
const [mousePosition, setMousePosition] = useState<MousePosition>({
x: 0,
y: 0,
})
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY })
}
window.addEventListener("mousemove", handleMouseMove)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
}
}, [])
return mousePosition
}
interface ParticlesProps extends ComponentPropsWithoutRef<"div"> {
className?: string
quantity?: number
staticity?: number
ease?: number
size?: number
refresh?: boolean
color?: string
vx?: number
vy?: number
}
function hexToRgb(hex: string): number[] {
hex = hex.replace("#", "")
if (hex.length === 3) {
hex = hex
.split("")
.map((char) => char + char)
.join("")
}
const hexInt = parseInt(hex, 16)
const red = (hexInt >> 16) & 255
const green = (hexInt >> 8) & 255
const blue = hexInt & 255
return [red, green, blue]
}
type Circle = {
x: number
y: number
translateX: number
translateY: number
size: number
alpha: number
targetAlpha: number
dx: number
dy: number
magnetism: number
}
export const Particles: React.FC<ParticlesProps> = ({
className = "",
quantity = 100,
staticity = 50,
ease = 50,
size = 0.4,
refresh = false,
color = "#ffffff",
vx = 0,
vy = 0,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const context = useRef<CanvasRenderingContext2D | null>(null)
const circles = useRef<Circle[]>([])
const mousePosition = MousePosition()
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1
const rafID = useRef<number | null>(null)
const resizeTimeout = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (canvasRef.current) {
context.current = canvasRef.current.getContext("2d")
}
initCanvas()
animate()
const handleResize = () => {
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current)
}
resizeTimeout.current = setTimeout(() => {
initCanvas()
}, 200)
}
window.addEventListener("resize", handleResize)
return () => {
if (rafID.current != null) {
window.cancelAnimationFrame(rafID.current)
}
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current)
}
window.removeEventListener("resize", handleResize)
}
}, [color])
useEffect(() => {
onMouseMove()
}, [mousePosition.x, mousePosition.y])
useEffect(() => {
initCanvas()
}, [refresh])
const initCanvas = () => {
resizeCanvas()
drawParticles()
}
const onMouseMove = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect()
const { w, h } = canvasSize.current
const x = mousePosition.x - rect.left - w / 2
const y = mousePosition.y - rect.top - h / 2
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
if (inside) {
mouse.current.x = x
mouse.current.y = y
}
}
}
const resizeCanvas = () => {
if (canvasContainerRef.current && canvasRef.current && context.current) {
canvasSize.current.w = canvasContainerRef.current.offsetWidth
canvasSize.current.h = canvasContainerRef.current.offsetHeight
canvasRef.current.width = canvasSize.current.w * dpr
canvasRef.current.height = canvasSize.current.h * dpr
canvasRef.current.style.width = `${canvasSize.current.w}px`
canvasRef.current.style.height = `${canvasSize.current.h}px`
context.current.scale(dpr, dpr)
// Clear existing particles and create new ones with exact quantity
circles.current = []
for (let i = 0; i < quantity; i++) {
const circle = circleParams()
drawCircle(circle)
}
}
}
const circleParams = (): Circle => {
const x = Math.floor(Math.random() * canvasSize.current.w)
const y = Math.floor(Math.random() * canvasSize.current.h)
const translateX = 0
const translateY = 0
const pSize = Math.floor(Math.random() * 2) + size
const alpha = 0
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
const dx = (Math.random() - 0.5) * 0.1
const dy = (Math.random() - 0.5) * 0.1
const magnetism = 0.1 + Math.random() * 4
return {
x,
y,
translateX,
translateY,
size: pSize,
alpha,
targetAlpha,
dx,
dy,
magnetism,
}
}
const rgb = hexToRgb(color)
const drawCircle = (circle: Circle, update = false) => {
if (context.current) {
const { x, y, translateX, translateY, size, alpha } = circle
context.current.translate(translateX, translateY)
context.current.beginPath()
context.current.arc(x, y, size, 0, 2 * Math.PI)
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`
context.current.fill()
context.current.setTransform(dpr, 0, 0, dpr, 0, 0)
if (!update) {
circles.current.push(circle)
}
}
}
const clearContext = () => {
if (context.current) {
context.current.clearRect(
0,
0,
canvasSize.current.w,
canvasSize.current.h
)
}
}
const drawParticles = () => {
clearContext()
const particleCount = quantity
for (let i = 0; i < particleCount; i++) {
const circle = circleParams()
drawCircle(circle)
}
}
const remapValue = (
value: number,
start1: number,
end1: number,
start2: number,
end2: number
): number => {
const remapped =
((value - start1) * (end2 - start2)) / (end1 - start1) + start2
return remapped > 0 ? remapped : 0
}
const animate = () => {
clearContext()
circles.current.forEach((circle: Circle, i: number) => {
// Handle the alpha value
const edge = [
circle.x + circle.translateX - circle.size, // distance from left edge
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
circle.y + circle.translateY - circle.size, // distance from top edge
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
]
const closestEdge = edge.reduce((a, b) => Math.min(a, b))
const remapClosestEdge = parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
)
if (remapClosestEdge > 1) {
circle.alpha += 0.02
if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha
}
} else {
circle.alpha = circle.targetAlpha * remapClosestEdge
}
circle.x += circle.dx + vx
circle.y += circle.dy + vy
circle.translateX +=
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
ease
circle.translateY +=
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
ease
drawCircle(circle, true)
// circle gets out of the canvas
if (
circle.x < -circle.size ||
circle.x > canvasSize.current.w + circle.size ||
circle.y < -circle.size ||
circle.y > canvasSize.current.h + circle.size
) {
// remove the circle from the array
circles.current.splice(i, 1)
// create a new circle
const newCircle = circleParams()
drawCircle(newCircle)
}
})
rafID.current = window.requestAnimationFrame(animate)
}
return (
<div
className={cn("pointer-events-none", className)}
ref={canvasContainerRef}
aria-hidden="true"
{...props}
>
<canvas ref={canvasRef} className="size-full" />
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { Currency } from "@/types/currency";
import { formatPrice, formatPriceWithTc } from "@/lib/currency-utils";
import { cn } from "@/lib/utils";
interface PriceDisplayProps {
value: number;
currency?: Currency;
showTc?: boolean;
className?: string;
size?: "sm" | "md" | "lg";
}
export function PriceDisplay({
value,
currency = "gp",
showTc = false,
className,
size = "md",
}: PriceDisplayProps) {
const sizeClasses = {
sm: "text-sm",
md: "text-lg",
lg: "text-2xl",
};
const formattedPrice = showTc
? formatPriceWithTc(value)
: formatPrice(value, currency);
return (
<span className={cn("font-bold", sizeClasses[size], className)}>
🪙 {formattedPrice}
</span>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
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"
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 }
+139
View File
@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+96
View File
@@ -0,0 +1,96 @@
import React, { ComponentPropsWithoutRef, CSSProperties } from "react"
import { cn } from "@/lib/utils"
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
shimmerColor?: string
shimmerSize?: string
borderRadius?: string
shimmerDuration?: string
background?: string
className?: string
children?: React.ReactNode
}
export const ShimmerButton = React.forwardRef<
HTMLButtonElement,
ShimmerButtonProps
>(
(
{
shimmerColor = "#ffffff",
shimmerSize = "0.05em",
shimmerDuration = "3s",
borderRadius = "100px",
background = "rgba(0, 0, 0, 1)",
className,
children,
...props
},
ref
) => {
return (
<button
style={
{
"--spread": "90deg",
"--shimmer-color": shimmerColor,
"--radius": borderRadius,
"--speed": shimmerDuration,
"--cut": shimmerSize,
"--bg": background,
} as CSSProperties
}
className={cn(
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)]",
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
className
)}
ref={ref}
{...props}
>
{/* spark container */}
<div
className={cn(
"-z-30 blur-[2px]",
"[container-type:size] absolute inset-0 overflow-visible"
)}
>
{/* spark */}
<div className="animate-shimmer-slide absolute inset-0 [aspect-ratio:1] h-[100cqh] [border-radius:0] [mask:none]">
{/* spark before */}
<div className="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" />
</div>
</div>
{children}
{/* Highlight */}
<div
className={cn(
"absolute inset-0 size-full",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]",
// transition
"transform-gpu transition-all duration-300 ease-in-out",
// on hover
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
// on click
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]"
)}
/>
{/* backdrop */}
<div
className={cn(
"absolute [inset:var(--cut)] -z-20 [border-radius:var(--radius)] [background:var(--bg)]"
)}
/>
</button>
)
}
)
ShimmerButton.displayName = "ShimmerButton"
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Width of the border in pixels
* @default 1
*/
borderWidth?: number
/**
* Duration of the animation in seconds
* @default 14
*/
duration?: number
/**
* Color of the border, can be a single color or an array of colors
* @default "#000000"
*/
shineColor?: string | string[]
}
/**
* Shine Border
*
* An animated background border effect component with configurable properties.
*/
export function ShineBorder({
borderWidth = 1,
duration = 14,
shineColor = "#000000",
className,
style,
...props
}: ShineBorderProps) {
return (
<div
style={
{
"--border-width": `${borderWidth}px`,
"--duration": `${duration}s`,
backgroundImage: `radial-gradient(transparent,transparent, ${
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
},transparent,transparent)`,
backgroundSize: "300% 300%",
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
WebkitMaskComposite: "xor",
maskComposite: "exclude",
padding: "var(--border-width)",
...style,
} as React.CSSProperties
}
className={cn(
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
className
)}
{...props}
/>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { useEffect, useState } from "react"
import { AnimatePresence, motion, MotionProps } from "motion/react"
import { cn } from "@/lib/utils"
interface WordRotateProps {
words: string[]
duration?: number
motionProps?: MotionProps
className?: string
}
export function WordRotate({
words,
duration = 2500,
motionProps = {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 50 },
transition: { duration: 0.25, ease: "easeOut" },
},
className,
}: WordRotateProps) {
const [index, setIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setIndex((prevIndex) => (prevIndex + 1) % words.length)
}, duration)
// Clean up interval on unmount
return () => clearInterval(interval)
}, [words, duration])
return (
<div className="overflow-hidden py-2">
<AnimatePresence mode="wait">
<motion.h1
key={words[index]}
className={cn(className)}
{...motionProps}
>
{words[index]}
</motion.h1>
</AnimatePresence>
</div>
)
}