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:
1 parent
55c169bd6e
commit
5c1ccbc69a
67 files changed
+8984
-160
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["pt", "en"],
|
||||
defaultLocale: "pt",
|
||||
});
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
Generated
+492
-4
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2515
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user