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

+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider";
import "../globals.css";
const nunitoSans = Nunito_Sans({
variable: "--font-nunito-sans",
subsets: ["latin"],
weight: ["200", "300", "400", "500", "600", "700", "800", "900"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Pagodera Trade",
description: "Markeplace servidor pagodera",
};
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
{children}
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Header from "@/components/Header";
import { EscrowBanner } from "@/components/sections/escrow-banner";
import { EscrowExplanation } from "@/components/sections/escrow-explanation";
import { HeroSection } from "@/components/sections/hero-section";
import { HowItWorks } from "@/components/sections/how-it-works";
import { FeaturedItems } from "@/components/sections/featured-items";
import { MarketOffers } from "@/components/sections/market-offers";
import { MarketStats } from "@/components/sections/market-stats";
export default function Home() {
return (
<div className="min-h-screen">
<Header />
<EscrowBanner />
<main>
<HeroSection />
<HowItWorks />
<FeaturedItems />
<MarketOffers />
<MarketStats />
</main>
<EscrowExplanation />
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center space-y-4">
<h2 className="text-2xl font-bold">Algo deu errado!</h2>
<p className="text-muted-foreground">
Ocorreu um erro inesperado. Tente novamente.
</p>
<Button onClick={reset}>Tentar novamente</Button>
</div>
</div>
);
}
+306 -14
View File
@@ -1,26 +1,318 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--animate-shimmer-slide: shimmer-slide var(--speed) ease-in-out infinite
alternate;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;
@keyframes shimmer-slide {
to {
transform: translate(calc(100cqw - 100%), 0);
}
}
@keyframes spin-around {
0% {
transform: translateZ(0) rotate(0);
}
15%,
35% {
transform: translateZ(0) rotate(90deg);
}
65%,
85% {
transform: translateZ(0) rotate(270deg);
}
100% {
transform: translateZ(0) rotate(360deg);
}
}
--animate-gradient: gradient 8s linear infinite;
@keyframes gradient {
to {
background-position: var(--bg-size, 300%) 0;
}
}
--animate-shine: shine var(--duration) infinite linear;
@keyframes shine {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
to {
background-position: 0% 0%;
}
}
--animate-background-position-spin: background-position-spin 3000ms infinite
alternate;
@keyframes background-position-spin {
0% {
background-position: top center;
}
100% {
background-position: bottom center;
}
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html,
body {
height: 100%;
}
#__next {
height: 100%;
}
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
padding-left: 2rem;
padding-right: 2rem;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.container {
max-width: 1280px; /* Manter 1280px como máximo */
}
}
}
/* Tibia-themed colors */
:root {
--tibia-green: oklch(0.6 0.15 145);
--tibia-gold: oklch(0.75 0.15 85);
--tibia-mystic: oklch(0.55 0.2 250);
--tibia-legendary: #ff8c00;
--tibia-epic: #9b59b6;
--tibia-rare: #3498db;
--tibia-uncommon: #2ecc71;
--tibia-common: #95a5a6;
--tibia-gold: #ffd700;
}
.dark {
--tibia-green: oklch(0.7 0.2 145);
--tibia-gold: oklch(0.8 0.2 85);
--tibia-mystic: oklch(0.6 0.25 250);
--tibia-legendary: #ff8c00;
--tibia-epic: #9b59b6;
--tibia-rare: #3498db;
--tibia-uncommon: #2ecc71;
--tibia-common: #95a5a6;
--tibia-gold: #ffd700;
}
/* Tibia glow effects */
@keyframes glow-legendary {
0%,
100% {
box-shadow: 0 0 15px rgba(255, 140, 0, 0.5);
}
50% {
box-shadow: 0 0 25px rgba(255, 140, 0, 0.8);
}
}
@keyframes glow-epic {
0%,
100% {
box-shadow: 0 0 15px rgba(155, 89, 182, 0.5);
}
50% {
box-shadow: 0 0 25px rgba(155, 89, 182, 0.8);
}
}
@keyframes glow-rare {
0%,
100% {
box-shadow: 0 0 15px rgba(52, 152, 219, 0.5);
}
50% {
box-shadow: 0 0 25px rgba(52, 152, 219, 0.8);
}
}
@keyframes glow-uncommon {
0%,
100% {
box-shadow: 0 0 15px rgba(46, 204, 113, 0.5);
}
50% {
box-shadow: 0 0 25px rgba(46, 204, 113, 0.8);
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
/* Utility classes for Tibia effects */
.glow-legendary {
animation: glow-legendary 2s ease-in-out infinite;
}
.glow-epic {
animation: glow-epic 2s ease-in-out infinite;
}
.glow-rare {
animation: glow-rare 2s ease-in-out infinite;
}
.glow-uncommon {
animation: glow-uncommon 2s ease-in-out infinite;
}
-34
View File
@@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center space-y-4">
<h2 className="text-2xl font-bold">Página não encontrada</h2>
<p className="text-muted-foreground">
A página que você está procurando não existe.
</p>
<Link href="/">
<Button>Voltar ao início</Button>
</Link>
</div>
</div>
);
}
-103
View File
@@ -1,103 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}.json"
}
}
+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>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
+7
View File
@@ -0,0 +1,7 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["pt", "en"],
defaultLocale: "pt",
});
+90
View File
@@ -0,0 +1,90 @@
import { Currency } from "@/types/currency";
export function formatPrice(value: number, currency: Currency = "gp"): string {
if (value >= 1000000) {
const millions = value / 1000000;
return `${millions.toFixed(1)}kk ${currency}`;
}
if (value >= 1000) {
const thousands = value / 1000;
return `${thousands.toFixed(0)}k ${currency}`;
}
return `${value.toLocaleString("pt-BR")} ${currency}`;
}
export function convertGpToTc(gp: number): number {
return Math.round(gp / 35000); // 1 TC ≈ 35.000 gp
}
export function formatLargeNumber(value: number): string {
if (value >= 1000000) {
const millions = value / 1000000;
return `${millions.toFixed(1)}kk`;
}
if (value >= 1000) {
const thousands = value / 1000;
return `${thousands.toFixed(0)}k`;
}
return value.toLocaleString("pt-BR");
}
export function formatPriceWithTc(gp: number): string {
const tc = convertGpToTc(gp);
if (tc >= 1) {
return `${formatPrice(gp)} (${tc} TC)`;
}
return formatPrice(gp);
}
export function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "text-orange-500";
case "epic":
return "text-purple-500";
case "rare":
return "text-blue-500";
case "uncommon":
return "text-green-500";
case "common":
return "text-gray-500";
default:
return "text-gray-500";
}
}
export function getRarityBgColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "bg-orange-500/10";
case "epic":
return "bg-purple-500/10";
case "rare":
return "bg-blue-500/10";
case "uncommon":
return "bg-green-500/10";
case "common":
return "bg-gray-500/10";
default:
return "bg-gray-500/10";
}
}
export function getRarityGlow(rarity: string): string {
switch (rarity) {
case "legendary":
return "shadow-[0_0_15px_rgba(255,140,0,0.5)]";
case "epic":
return "shadow-[0_0_15px_rgba(155,89,182,0.5)]";
case "rare":
return "shadow-[0_0_15px_rgba(52,152,219,0.5)]";
case "uncommon":
return "shadow-[0_0_15px_rgba(46,204,113,0.5)]";
default:
return "";
}
}
+198
View File
@@ -0,0 +1,198 @@
import { TibiaItem, MarketOffer } from "@/types/item";
export const featuredItems: TibiaItem[] = [
{
id: "1",
name: "Armor of Fire",
image: "/items/armor-of-fire.png",
category: "armor",
rarity: "legendary",
stats: { armor: 16, weight: 120 },
requirements: { level: 200, vocation: "Knight" },
protections: { fire: 10 },
price: { buy: 2500000, sell: 2200000, bid: 2350000 },
priceHistory: { highest: 2800000, lowest: 2000000, average: 2350000 },
dropLocation: "Fire Dragon Lair",
},
{
id: "2",
name: "Demon Helmet",
image: "/items/demon-helmet.png",
category: "helmet",
rarity: "epic",
stats: { armor: 10, weight: 29 },
requirements: { level: 100, vocation: "Knight" },
price: { buy: 50000, sell: 45000, bid: 47500 },
priceHistory: { highest: 55000, lowest: 42000, average: 48500 },
dropLocation: "Demon Hell",
},
{
id: "3",
name: "Vampire Shield",
image: "/items/vampire-shield.png",
category: "shield",
rarity: "epic",
stats: { defense: 38, weight: 48 },
requirements: { level: 150, vocation: "Knight" },
price: { buy: 450000, sell: 420000, bid: 435000 },
priceHistory: { highest: 480000, lowest: 400000, average: 435000 },
dropLocation: "Vampire Crypt",
},
{
id: "4",
name: "Zaoan Helmet",
image: "/items/zaoan-helmet.png",
category: "helmet",
rarity: "rare",
stats: { armor: 9, weight: 43 },
requirements: { level: 80, vocation: "Knight" },
price: { buy: 85000, sell: 75000, bid: 80000 },
priceHistory: { highest: 95000, lowest: 70000, average: 80000 },
dropLocation: "Zao Palace",
},
{
id: "5",
name: "Magic Sword",
image: "/items/magic-sword.png",
category: "weapon",
rarity: "rare",
stats: { attack: 48, defense: 35, weight: 36 },
requirements: { level: 75, vocation: "Knight" },
price: { buy: 22000, sell: 20000, bid: 21000 },
priceHistory: { highest: 28000, lowest: 20000, average: 24000 },
dropLocation: "Magic Shop",
},
{
id: "6",
name: "Demonbone Amulet",
image: "/items/demonbone-amulet.png",
category: "amulet",
rarity: "epic",
stats: { armor: 2, weight: 8.5 },
protections: { fire: 10 },
price: { buy: 180000, sell: 165000, bid: 172500 },
priceHistory: { highest: 200000, lowest: 150000, average: 175000 },
dropLocation: "Demon Hell",
},
{
id: "7",
name: "Ornate Chestplate",
image: "/items/ornate-chestplate.png",
category: "armor",
rarity: "legendary",
stats: { armor: 18, weight: 185 },
requirements: { level: 300, vocation: "Knight" },
protections: { fire: 5, ice: 5 },
price: { buy: 8500000, sell: 8200000, bid: 8350000 },
priceHistory: { highest: 9000000, lowest: 8000000, average: 8400000 },
dropLocation: "Ornate Chest",
},
{
id: "8",
name: "Ultimate Health Potion",
image: "/items/uhp.png",
category: "potion",
rarity: "common",
stats: { weight: 2 },
stackable: true,
price: { buy: 310, sell: 280, bid: 295 },
priceHistory: { highest: 350, lowest: 280, average: 300 },
dropLocation: "Magic Shop",
},
];
export const buyOffers: MarketOffer[] = [
{
id: "1",
item: featuredItems[7], // Ultimate Health Potion
seller: "EternalOblivion",
price: 280000,
quantity: 1000,
type: "buy",
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
},
{
id: "2",
item: featuredItems[1], // Demon Helmet
seller: "PallySupreme",
price: 48000,
quantity: 1,
type: "buy",
timestamp: new Date(Date.now() - 1000 * 60 * 45), // 45 min ago
},
{
id: "3",
item: featuredItems[5], // Demonbone Amulet
seller: "MageDestroyer",
price: 175000,
quantity: 1,
type: "buy",
timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
},
{
id: "4",
item: featuredItems[4], // Magic Sword
seller: "KnightLegacy",
price: 25000,
quantity: 1,
type: "buy",
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 3), // 3 hours ago
},
{
id: "5",
item: featuredItems[6], // Ornate Chestplate
seller: "TibiaCollector",
price: 8300000,
quantity: 1,
type: "buy",
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 min ago
},
];
export const sellOffers: MarketOffer[] = [
{
id: "6",
item: featuredItems[7], // Ultimate Health Potion
seller: "EternalOblivion",
price: 280000,
quantity: 1000,
type: "sell",
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
},
{
id: "7",
item: featuredItems[1], // Demon Helmet
seller: "PallySupreme",
price: 48000,
quantity: 1,
type: "sell",
timestamp: new Date(Date.now() - 1000 * 60 * 45), // 45 min ago
},
{
id: "8",
item: featuredItems[5], // Demonbone Amulet
seller: "MageDestroyer",
price: 175000,
quantity: 1,
type: "sell",
timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
},
{
id: "9",
item: featuredItems[4], // Magic Sword
seller: "KnightLegacy",
price: 25000,
quantity: 1,
type: "sell",
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 3), // 3 hours ago
},
{
id: "10",
item: featuredItems[6], // Ornate Chestplate
seller: "TibiaCollector",
price: 8300000,
quantity: 1,
type: "sell",
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 min ago
},
];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+177
View File
@@ -0,0 +1,177 @@
{
"header": {
"nav": {
"market": "Market",
"about": "About",
"prices": "Prices"
},
"theme": {
"light": "Light Theme",
"dark": "Dark Theme",
"system": "System"
},
"language": "Language",
"menu": "Menu"
},
"home": {
"hero": {
"title": "Buy and Sell Tibia Items",
"subtitle": "Safe and fast marketplace for Pagodera server",
"cta": "View Market"
},
"howItWorks": {
"title": "How It Works",
"buy": "Buy rare items",
"sell": "Sell your loot",
"trade": "Trade safely"
},
"featured": {
"title": "Featured Items"
},
"offers": {
"buying": "Looking to Buy",
"selling": "Selling Now"
},
"stats": {
"title": "Market Statistics",
"activeOffers": "Active Offers",
"itemsSold": "Items Sold",
"users": "Active Users",
"totalVolume": "Total Volume Traded",
"vsLastMonth": "vs last month",
"updated": "Updated",
"minutes": "minutes",
"hours": "hours",
"onlineNow": "Online now",
"traders": "traders",
"averagePerDay": "Average",
"salesPerDay": "sales/day",
"today": "Today"
},
"currency": {
"gp": "gold pieces",
"tc": "Tibia Coins",
"kk": "kk"
},
"vocation": {
"knight": "Knight",
"paladin": "Paladin",
"sorcerer": "Sorcerer",
"druid": "Druid",
"noVocation": "No Vocation"
},
"protection": {
"fire": "Fire Protection",
"ice": "Ice Protection",
"energy": "Energy Protection",
"earth": "Earth Protection",
"death": "Death Protection",
"holy": "Holy Protection"
},
"attributes": {
"speedBoost": "Speed Boost",
"manaRegen": "Mana Regen",
"hpRegen": "HP Regen",
"skillBoost": "Skill Boost"
},
"buttons": {
"deposit": "Deposit",
"negotiate": "Negotiate",
"viewProfile": "View Profile",
"buyNow": "Buy Now",
"viewDetails": "View Details"
},
"status": {
"hotDeal": "Hot Deal",
"new": "New",
"onlineNow": "Online now"
},
"categories": {
"armors": "Armors",
"helmets": "Helmets",
"weapons": "Weapons",
"shields": "Shields",
"potions": "Potions",
"runes": "Runes",
"tools": "Tools"
},
"escrow": {
"title": "Trade safely",
"description": "Secure escrow system protects your transactions",
"deposit": "Deposit"
},
"escrowExplanation": {
"title": "Trade with Total Security",
"subtitle": "Escrow system protects 100% of your transactions",
"step1": {
"title": "You Deposit",
"description": "Deposit your gold pieces or Tibia Coins on the platform. Your money stays safe in custody until the negotiation is completed.",
"example": "8.500.000 gp → Escrow Account"
},
"step2": {
"title": "Secure Trade",
"description": "The seller receives notification and delivers the item in-game. Both are protected: you have guaranteed money, they know they'll be paid.",
"example": "Ornate Chestplate → In-game transfer"
},
"step3": {
"title": "Automatic Payment",
"description": "After confirming receipt, the system automatically releases payment to the seller. If there's a problem, you can open a dispute.",
"example": "8.500.000 gp → Seller + 2% Fee"
},
"benefits": {
"protection": {
"title": "Scam Protection",
"description": "Impossible to lose your gold. Money is only released after confirmation."
},
"fast": {
"title": "Fast Transactions",
"description": "Automated process. Most negotiations completed in minutes."
},
"history": {
"title": "Complete History",
"description": "All transactions recorded. Access your history anytime."
},
"trusted": {
"title": "Trusted System",
"description": "Used by 3.421+ players. 99.8% success rate in transactions."
}
},
"faq": {
"title": "Frequently Asked Questions",
"q1": "What happens if the seller doesn't deliver the item?",
"a1": "You can open a dispute within 24 hours. Our team analyzes the case and, if proven, the money returns 100% to your account.",
"q2": "How long is the money held in escrow?",
"a2": "Until you confirm receipt of the item or until the 48-hour deadline. After this period, if there's no dispute, payment is released automatically.",
"q3": "What's the escrow system fee?",
"a3": "We charge only 2% of the transaction value, split between buyer and seller (1% each). It's the lowest fee in the market.",
"q4": "Can I cancel a transaction?",
"a4": "Yes, before the seller sends the item. After delivery is confirmed by the seller, you have 24h to refuse if there's a problem.",
"q5": "Does escrow accept Tibia Coins or just gold?",
"a5": "We accept both! You can deposit gold pieces (gp) or Tibia Coins (TC). Conversion is done automatically by Market rates."
},
"cta": {
"title": "Ready to trade safely?",
"createOffer": "Create My First Offer",
"watchVideo": "See How It Works"
},
"stats": {
"volume": "gp transacted safely",
"transactions": "transactions completed",
"satisfaction": "user satisfaction",
"avgTime": "average completion time"
}
},
"item": {
"buyPrice": "Buy",
"sellPrice": "Sell",
"bidPrice": "Bid",
"priceHistory": "Price History",
"highest": "Highest",
"lowest": "Lowest",
"average": "Average",
"stats": "Statistics",
"viewDetails": "View Details",
"buyNow": "Buy Now"
}
}
}
+177
View File
@@ -0,0 +1,177 @@
{
"header": {
"nav": {
"market": "Mercado",
"about": "Sobre",
"prices": "Preços"
},
"theme": {
"light": "Tema Claro",
"dark": "Tema Escuro",
"system": "Sistema"
},
"language": "Idioma",
"menu": "Menu"
},
"home": {
"hero": {
"title": "Compre e Venda Itens de Tibia",
"subtitle": "Marketplace seguro e rápido para o servidor Pagodera",
"cta": "Ver Mercado"
},
"howItWorks": {
"title": "Como Funciona",
"buy": "Compre itens raros",
"sell": "Venda seu loot",
"trade": "Negocie com segurança"
},
"featured": {
"title": "Itens em Destaque"
},
"offers": {
"buying": "Procurando Comprar",
"selling": "Vendendo Agora"
},
"stats": {
"title": "Estatísticas do Mercado",
"activeOffers": "Ofertas Ativas",
"itemsSold": "Itens Vendidos",
"users": "Usuários Ativos",
"totalVolume": "Volume Total Negociado",
"vsLastMonth": "vs mês anterior",
"updated": "Atualizado há",
"minutes": "minutos",
"hours": "horas",
"onlineNow": "Online agora",
"traders": "traders",
"averagePerDay": "Média",
"salesPerDay": "vendas/dia",
"today": "Hoje"
},
"currency": {
"gp": "gold pieces",
"tc": "Tibia Coins",
"kk": "kk"
},
"vocation": {
"knight": "Knight",
"paladin": "Paladin",
"sorcerer": "Sorcerer",
"druid": "Druid",
"noVocation": "No Vocation"
},
"protection": {
"fire": "Fire Protection",
"ice": "Ice Protection",
"energy": "Energy Protection",
"earth": "Earth Protection",
"death": "Death Protection",
"holy": "Holy Protection"
},
"attributes": {
"speedBoost": "Speed Boost",
"manaRegen": "Mana Regen",
"hpRegen": "HP Regen",
"skillBoost": "Skill Boost"
},
"buttons": {
"deposit": "Depositar",
"negotiate": "Negociar",
"viewProfile": "Ver Perfil",
"buyNow": "Comprar Agora",
"viewDetails": "Ver Detalhes"
},
"status": {
"hotDeal": "Hot Deal",
"new": "Novo",
"onlineNow": "Online agora"
},
"categories": {
"armors": "Armors",
"helmets": "Helmets",
"weapons": "Weapons",
"shields": "Shields",
"potions": "Potions",
"runes": "Runes",
"tools": "Tools"
},
"escrow": {
"title": "Negocie com segurança",
"description": "Sistema seguro de escrow protege suas transações",
"deposit": "Depositar"
},
"escrowExplanation": {
"title": "Negocie com Segurança Total",
"subtitle": "Sistema de Escrow protege 100% das suas transações",
"step1": {
"title": "Você Deposita",
"description": "Deposite seus gold pieces ou Tibia Coins na plataforma. Seu dinheiro fica seguro em custódia até a conclusão da negociação.",
"example": "8.500.000 gp → Conta Escrow"
},
"step2": {
"title": "Troca Segura",
"description": "O vendedor recebe notificação e entrega o item in-game. Ambos estão protegidos: você tem o dinheiro garantido, ele sabe que será pago.",
"example": "Ornate Chestplate → Transferência no jogo"
},
"step3": {
"title": "Pagamento Automático",
"description": "Após confirmar o recebimento, o sistema libera automaticamente o pagamento para o vendedor. Se houver problema, você pode abrir disputa.",
"example": "8.500.000 gp → Vendedor + Taxa 2%"
},
"benefits": {
"protection": {
"title": "Proteção Contra Scam",
"description": "Impossível perder seu gold. O dinheiro só é liberado após confirmação."
},
"fast": {
"title": "Transações Rápidas",
"description": "Processo automatizado. Maioria das negociações concluídas em minutos."
},
"history": {
"title": "Histórico Completo",
"description": "Todas as transações registradas. Acesse seu histórico a qualquer momento."
},
"trusted": {
"title": "Sistema Confiável",
"description": "Usado por 3.421+ jogadores. Taxa de sucesso de 99.8% nas transações."
}
},
"faq": {
"title": "Perguntas Frequentes",
"q1": "O que acontece se o vendedor não entregar o item?",
"a1": "Você pode abrir uma disputa em até 24 horas. Nossa equipe analisa o caso e, se comprovado, o dinheiro retorna 100% para sua conta.",
"q2": "Quanto tempo o dinheiro fica retido no escrow?",
"a2": "Até você confirmar o recebimento do item ou até o prazo de 48 horas. Após esse período, se não houver disputa, o pagamento é liberado automaticamente.",
"q3": "Qual a taxa do sistema escrow?",
"a3": "Cobramos apenas 2% sobre o valor da transação, dividido entre comprador e vendedor (1% cada). É a menor taxa do mercado.",
"q4": "Posso cancelar uma transação?",
"a4": "Sim, antes do vendedor enviar o item. Após a entrega ser confirmada pelo vendedor, você tem 24h para recusar se houver problema.",
"q5": "O escrow aceita Tibia Coins ou apenas gold?",
"a5": "Aceitamos ambos! Você pode depositar gold pieces (gp) ou Tibia Coins (TC). A conversão é feita automaticamente pela cotação do Market."
},
"cta": {
"title": "Pronto para negociar com segurança?",
"createOffer": "Criar Minha Primeira Oferta",
"watchVideo": "Ver Como Funciona"
},
"stats": {
"volume": "gp transacionados com segurança",
"transactions": "transações completadas",
"satisfaction": "de satisfação dos usuários",
"avgTime": "tempo médio de conclusão"
}
},
"item": {
"buyPrice": "Compra",
"sellPrice": "Venda",
"bidPrice": "Lance",
"priceHistory": "Histórico de Preço",
"highest": "Mais Alto",
"lowest": "Mais Baixo",
"average": "Média",
"stats": "Estatísticas",
"viewDetails": "Ver Detalhes",
"buyNow": "Comprar Agora"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/", "/(pt|en)/:path*"],
};
+4 -1
View File
@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
export default withNextIntl(nextConfig);
+492 -4
View File
@@ -8,9 +8,17 @@
"name": "pagodera",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"lucide-react": "^0.540.0",
"motion": "^12.23.24",
"next": "15.5.0",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
@@ -19,6 +27,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "^5"
}
},
@@ -823,6 +832,352 @@
"node": ">= 10"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1122,7 +1477,7 @@
"version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1132,7 +1487,7 @@
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@@ -1168,12 +1523,42 @@
"node": ">=18"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cobe": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/cobe/-/cobe-0.6.5.tgz",
"integrity": "sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==",
"license": "MIT",
"dependencies": {
"phenomenon": "^1.6.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -1223,7 +1608,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -1250,6 +1635,33 @@
"node": ">=10.13.0"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1513,6 +1925,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "0.540.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.540.0.tgz",
"integrity": "sha512-armkCAqQvO62EIX4Hq7hqX/q11WSZu0Jd23cnnqx0/49yIxGXyL/zyZfBxNN9YDx0ensPTb4L+DjTh3yQXUxtQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -1562,6 +1983,47 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz",
"integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.24",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1660,6 +2122,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/phenomenon": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/phenomenon/-/phenomenon-1.6.0.tgz",
"integrity": "sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1820,6 +2288,16 @@
}
}
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
@@ -1861,6 +2339,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.7.tgz",
"integrity": "sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+23 -4
View File
@@ -10,17 +10,36 @@
"format": "biome format --write"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"lucide-react": "^0.540.0",
"motion": "^12.23.24",
"next": "15.5.0",
"next-intl": "^4.4.0",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.0"
"recharts": "^3.3.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"typescript": "^5",
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"@biomejs/biome": "2.2.0"
"tw-animate-css": "^1.3.7",
"typescript": "^5"
}
}
+2515
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
export type Currency = "gp" | "tc";
export interface Price {
value: number;
currency: Currency;
tcEquivalent?: number; // 1 TC ≈ 35.000 gp
}
export interface TibiaProtection {
fire?: number;
ice?: number;
energy?: number;
earth?: number;
death?: number;
holy?: number;
}
export interface TibiaStats {
attack?: number;
defense?: number;
armor?: number;
weight?: number;
speed?: number;
manaRegen?: number;
hpRegen?: number;
skillBoost?: {
sword?: number;
axe?: number;
club?: number;
distance?: number;
shielding?: number;
fishing?: number;
};
}
export interface TibiaRequirements {
level?: number;
vocation?: "Knight" | "Paladin" | "Sorcerer" | "Druid" | "No Vocation";
}
+48
View File
@@ -0,0 +1,48 @@
import { TibiaStats, TibiaRequirements, TibiaProtection } from "./currency";
export interface TibiaItem {
id: string;
name: string;
image: string;
category:
| "weapon"
| "armor"
| "helmet"
| "legs"
| "boots"
| "shield"
| "amulet"
| "ring"
| "potion"
| "rune"
| "tool"
| "ammunition"
| "other";
rarity: "common" | "uncommon" | "rare" | "epic" | "legendary";
stats?: TibiaStats;
requirements?: TibiaRequirements;
protections?: TibiaProtection;
stackable?: boolean;
dropLocation?: string;
npcPrice?: number;
price: {
buy: number;
sell: number;
bid?: number;
};
priceHistory: {
highest: number;
lowest: number;
average: number;
};
}
export interface MarketOffer {
id: string;
item: TibiaItem;
seller: string;
price: number;
quantity: number;
type: "buy" | "sell";
timestamp: Date;
}
+29
View File
@@ -0,0 +1,29 @@
aaron-bond.better-comments
abians.prisma-generate-uml
alefragnani.project-manager
christian-kohler.path-intellisense
codezombiech.gitignore
dbaeumer.vscode-eslint
donjayamanne.githistory
eamodio.gitlens
ecmel.vscode-html-css
equinusocio.vsc-material-theme
equinusocio.vsc-material-theme-icons
esbenp.prettier-vscode
formulahendry.auto-rename-tag
github.remotehub
github.vscode-github-actions
github.vscode-pull-request-github
mhutchie.git-graph
miguelsolorio.symbols
ms-vscode-remote.remote-ssh
ms-vscode-remote.remote-ssh-edit
ms-vscode.azure-repos
ms-vscode.remote-explorer
ms-vscode.remote-repositories
ms-vscode.vscode-typescript-next
prisma.prisma
redhat.vscode-yaml
tal7aouy.rainbow-bracket
usernamehw.errorlens
wix.vscode-import-cost