style: clean up code formatting and remove trailing whitespace

refactor: reorganize imports and fix type imports
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-11 03:08:36 -03:00
1 parent d3704602fd
commit 47c04de6fe
232 files changed
+7140 -4798

No files matched your search

+35 -29
View File
@@ -1,20 +1,11 @@
"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 { NotificationBell } from "./notifications/NotificationBell";
import { MessagesBell } from "./messages/MessagesBell";
import { LoginDialog } from "./auth/login-dialog";
import { RegisterDialog } from "./auth/register-dialog";
import { LogOut, User } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -24,14 +15,23 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { WordRotate } from "@/components/ui/word-rotate";
import { useSession } from "@/lib/hooks/use-session";
import Link from "next/link";
import { useState, useEffect } from "react";
import { LogOut, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { LoginDialog } from "./auth/login-dialog";
import { RegisterDialog } from "./auth/register-dialog";
import { LanguageSwitcher } from "./language-switcher";
import { MessagesBell } from "./messages/MessagesBell";
import { MobileMenu } from "./mobile-menu";
import { NotificationBell } from "./notifications/NotificationBell";
import { ThemeToggle } from "./theme-toggle";
export default function Header() {
const t = useTranslations("header.nav");
@@ -90,7 +90,7 @@ export default function Header() {
navigationMenuTriggerStyle(),
"bg-transparent",
highlight &&
"bg-primary text-primary-foreground hover:bg-primary/90"
"bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
{label}
@@ -105,10 +105,10 @@ export default function Header() {
<div className="hidden md:flex items-center gap-2">
<ThemeToggle />
<LanguageSwitcher />
{/* Messages */}
{mounted && isAuthenticated && <MessagesBell />}
{/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />}
{mounted && isAuthenticated && user?.subscriptionTier && (
@@ -116,7 +116,7 @@ export default function Header() {
{user.subscriptionTier.slug}
</Badge>
)}
{/* Auth Section */}
{!mounted ? (
// Placeholder durante SSR para evitar mismatch
@@ -126,7 +126,10 @@ export default function Header() {
) : isAuthenticated && user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Button
variant="ghost"
className="relative h-8 w-8 rounded-full"
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
@@ -191,9 +194,12 @@ export default function Header() {
{/* Login Dialog */}
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
{/* Register Dialog */}
<RegisterDialog open={registerDialogOpen} onOpenChange={setRegisterDialogOpen} />
<RegisterDialog
open={registerDialogOpen}
onOpenChange={setRegisterDialogOpen}
/>
</header>
);
}
+25 -15
View File
@@ -1,10 +1,13 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminSubscriptionTab } from "@/components/admin/AdminSubscriptionTab";
import { AdminItemManager, type AdminItemsResponse } from "@/components/admin/items/AdminItemManager";
import {
AdminItemManager,
type AdminItemsResponse,
} from "@/components/admin/items/AdminItemManager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface AdminPanelTabsProps {
adminUser: {
@@ -28,9 +31,9 @@ export function AdminPanelTabs({
locale,
initialTab = "overview",
}: AdminPanelTabsProps) {
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
initialTab
);
const [activeTab, setActiveTab] = useState<
"overview" | "subscriptions" | "items"
>(initialTab);
return (
<Tabs
@@ -40,31 +43,39 @@ export function AdminPanelTabs({
>
<TabsList>
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
<TabsTrigger value="subscriptions">Assinaturas & Permissões</TabsTrigger>
<TabsTrigger value="subscriptions">
Assinaturas & Permissões
</TabsTrigger>
<TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Bem-vindo, {adminUser?.username ?? "Administrador"}</CardTitle>
<CardTitle>
Bem-vindo, {adminUser?.username ?? "Administrador"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
Utilize as abas acima para administrar planos de assinatura, permissões
e a curadoria de itens do marketplace. Esta área é exclusiva para
operadores autorizados.
Utilize as abas acima para administrar planos de assinatura,
permissões e a curadoria de itens do marketplace. Esta área é
exclusiva para operadores autorizados.
</p>
<p>
Para solicitações críticas, lembre-se de registrar mudanças e validar
com a equipe responsável antes de disponibilizar aos usuários finais.
Para solicitações críticas, lembre-se de registrar mudanças e
validar com a equipe responsável antes de disponibilizar aos
usuários finais.
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="subscriptions">
<AdminSubscriptionTab initialTiers={initialTiers} adminUser={adminUser} />
<AdminSubscriptionTab
initialTiers={initialTiers}
adminUser={adminUser}
/>
</TabsContent>
<TabsContent value="items">
@@ -73,4 +84,3 @@ export function AdminPanelTabs({
</Tabs>
);
}
+2 -3
View File
@@ -27,8 +27,8 @@ export function AdminSubscriptionTab({
</h2>
<p className="text-sm text-muted-foreground max-w-2xl">
Configure planos, recursos e vincule usuários às assinaturas
disponíveis. Todas as alterações são aplicadas imediatamente
após salvar.
disponíveis. Todas as alterações são aplicadas imediatamente após
salvar.
</p>
{adminUser?.subscription_tier && (
<p className="text-xs text-muted-foreground">
@@ -47,4 +47,3 @@ export function AdminSubscriptionTab({
</div>
);
}
+18 -8
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant";
@@ -13,7 +13,11 @@ interface AdminItemCreatePageProps {
serverOptions: string[];
}
export function AdminItemCreatePage({ locale, categories, serverOptions }: AdminItemCreatePageProps) {
export function AdminItemCreatePage({
locale,
categories,
serverOptions,
}: AdminItemCreatePageProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -54,14 +58,20 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
});
if (!response.ok) {
const data = await response.json().catch(() => ({ error: "Erro ao criar item" }));
const data = await response
.json()
.catch(() => ({ error: "Erro ao criar item" }));
throw new Error(data?.error || data?.message || "Falha ao criar item");
}
goBackToList();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao criar item. Tente novamente.");
setError(
err instanceof Error
? err.message
: "Erro ao criar item. Tente novamente.",
);
} finally {
setSubmitting(false);
}
@@ -73,8 +83,9 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Novo item</h1>
<p className="text-muted-foreground max-w-2xl">
Utilize a seção de IA abaixo para gerar automaticamente campos sugeridos a partir de prints e descrições.
Ajuste o que for necessário antes de publicar.
Utilize a seção de IA abaixo para gerar automaticamente campos
sugeridos a partir de prints e descrições. Ajuste o que for
necessário antes de publicar.
</p>
</div>
<Button variant="outline" onClick={goBackToList} className="gap-2">
@@ -120,4 +131,3 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
interface AdminItemCreateViewProps {
@@ -10,7 +10,11 @@ interface AdminItemCreateViewProps {
serverOptions: string[];
}
export function AdminItemCreateView({ locale, categories, serverOptions }: AdminItemCreateViewProps) {
export function AdminItemCreateView({
locale,
categories,
serverOptions,
}: AdminItemCreateViewProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
@@ -43,7 +47,9 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Erro ao criar item" }));
const errorPayload = await response
.json()
.catch(() => ({ error: "Erro ao criar item" }));
throw new Error(errorPayload?.error || "Falha ao criar item");
}
@@ -75,4 +81,3 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
</div>
);
}
File diff suppressed because it is too large. Load diff
+197 -58
View File
@@ -1,19 +1,34 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RefreshCw, Plus, Pencil } from "lucide-react";
import Image from "next/image";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { ItemCreationModal, type ItemFormPayload } from "@/components/admin/items/ItemCreationModal";
import { Pencil, Plus, RefreshCw } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ItemCreationModal,
type ItemFormPayload,
} from "@/components/admin/items/ItemCreationModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface AdminItemRecord {
item_id: number;
@@ -50,12 +65,20 @@ interface AdminItemManagerProps {
locale: string;
}
export function AdminItemManager({ initialData, locale }: AdminItemManagerProps) {
export function AdminItemManager({
initialData,
locale,
}: AdminItemManagerProps) {
const router = useRouter();
const [data, setData] = useState<AdminItemsResponse>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiStatus, setApiStatus] = useState<{ code: number | null; ok: boolean | null; durationMs: number | null; checkedAt: string | null }>({ code: null, ok: null, durationMs: null, checkedAt: null });
const [apiStatus, setApiStatus] = useState<{
code: number | null;
ok: boolean | null;
durationMs: number | null;
checkedAt: string | null;
}>({ code: null, ok: null, durationMs: null, checkedAt: null });
const [serverFilter, setServerFilter] = useState<string>("all");
const [search, setSearch] = useState<string>("");
@@ -65,10 +88,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const pageSize = initialData.pageSize ?? 50;
const [modalOpen, setModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeServers, setActiveServers] = useState<string[]>([]);
const [serversLoading, setServersLoading] = useState<boolean>(false);
const [_serversLoading, setServersLoading] = useState<boolean>(false);
const searchDebounce = useRef<number | undefined>(undefined);
@@ -96,13 +121,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
cache: "no-store",
});
if (!res.ok) {
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status);
console.warn(
"[AdminItemManager] falha ao buscar servidores ativos:",
res.status,
);
return;
}
const json = await res.json();
const names: string[] = Array.isArray(json?.servers)
? json.servers
.map((s: any) => (typeof s?.serverName === "string" ? s.serverName : null))
.map((s: any) =>
typeof s?.serverName === "string" ? s.serverName : null,
)
.filter((n: string | null): n is string => Boolean(n))
: [];
if (!cancelled) {
@@ -147,8 +177,19 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
signal: controller.signal,
});
const durationMs = Math.round(performance.now() - start);
setApiStatus({ code: res.status, ok: res.ok, durationMs, checkedAt: new Date().toISOString() });
console.log("Status:", res.status, res.statusText, "Duration:", `${durationMs}ms`);
setApiStatus({
code: res.status,
ok: res.ok,
durationMs,
checkedAt: new Date().toISOString(),
});
console.log(
"Status:",
res.status,
res.statusText,
"Duration:",
`${durationMs}ms`,
);
console.log("Headers:", {
"content-type": res.headers.get("content-type"),
"x-powered-by": res.headers.get("x-powered-by"),
@@ -165,10 +206,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
res.status === 401
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
: res.status === 403
? "Acesso restrito ao administrador."
: "Não foi possível carregar os itens.";
? "Acesso restrito ao administrador."
: "Não foi possível carregar os itens.";
const apiMsg = errorPayload?.error || errorPayload?.message || null;
const composed = apiMsg ? `${baseMsg} (${apiMsg})` : `${baseMsg} (HTTP ${res.status})`;
const composed = apiMsg
? `${baseMsg} (${apiMsg})`
: `${baseMsg} (HTTP ${res.status})`;
console.error("Erro na API:", composed, "Payload:", errorPayload);
throw new Error(composed);
}
@@ -179,23 +222,42 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
throw new Error("Resposta inválida da API de itens.");
}
setData(json);
console.log("Itens carregados:", { total: json.total, page: json.page, pageSize: json.pageSize });
console.log("Itens carregados:", {
total: json.total,
page: json.page,
pageSize: json.pageSize,
});
} catch (err: any) {
const durationMs = Math.round(performance.now() - start);
setApiStatus((prev) => ({ code: prev.code, ok: false, durationMs, checkedAt: new Date().toISOString() }));
setApiStatus((prev) => ({
code: prev.code,
ok: false,
durationMs,
checkedAt: new Date().toISOString(),
}));
if (err?.name === "AbortError") {
console.error("Requisição abortada por timeout");
setError("Tempo de resposta excedido. Tente novamente.");
} else {
console.error("Falha ao carregar itens:", err);
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
setError(
err instanceof Error ? err.message : "Falha ao carregar itens.",
);
}
} finally {
console.groupEnd();
window.clearTimeout(timeoutId);
setLoading(false);
}
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
}, [
search,
serverFilter,
categoryFilter,
rarityFilter,
page,
pageSize,
isAdminItemsResponse,
]);
useEffect(() => {
if (searchDebounce.current !== undefined) {
@@ -219,7 +281,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
set.add(item.rarity);
}
});
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
return Array.from(set).sort((a, b) =>
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
);
}, [data.items]);
const openEditModal = (item: AdminItemRecord) => {
@@ -257,8 +321,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Erro desconhecido" }));
throw new Error(error?.error || error?.message || "Falha ao salvar item");
const error = await res
.json()
.catch(() => ({ error: "Erro desconhecido" }));
throw new Error(
error?.error || error?.message || "Falha ao salvar item",
);
}
await fetchItems();
@@ -281,12 +349,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<div className="space-y-1">
<CardTitle>Todos os itens cadastrados</CardTitle>
<CardDescription>
Visualize e edite os registros unificados importados do database do Tibia Wiki.
Itens específicos de servidores podem ser marcados através das tags.
Visualize e edite os registros unificados importados do database
do Tibia Wiki. Itens específicos de servidores podem ser marcados
através das tags.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
<Button
variant="outline"
size="sm"
onClick={fetchItems}
disabled={loading}
>
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
</Button>
<Button
@@ -309,7 +383,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<Select value={categoryFilter} onValueChange={(value) => { setCategoryFilter(value); setPage(1); }}>
<Select
value={categoryFilter}
onValueChange={(value) => {
setCategoryFilter(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="Categoria" />
</SelectTrigger>
@@ -322,7 +402,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
))}
</SelectContent>
</Select>
<Select value={rarityFilter} onValueChange={(value) => { setRarityFilter(value); setPage(1); }}>
<Select
value={rarityFilter}
onValueChange={(value) => {
setRarityFilter(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="Raridade" />
</SelectTrigger>
@@ -340,7 +426,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{apiStatus.code !== null && (
<Badge variant="outline" className="text-xs">
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""}
{apiStatus.durationMs !== null
? ` • ${apiStatus.durationMs}ms`
: ""}
</Badge>
)}
</div>
@@ -350,7 +438,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{error && (
<Card className="border-destructive/40 bg-destructive/10">
<CardContent className="py-3 text-sm text-destructive">{error}</CardContent>
<CardContent className="py-3 text-sm text-destructive">
{error}
</CardContent>
</Card>
)}
@@ -382,26 +472,48 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<table className="w-full table-auto border-collapse min-w-[960px]">
<thead>
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide">
<th className="border border-border px-3 py-2 text-left">Imagem</th>
<th className="border border-border px-3 py-2 text-left">Nome</th>
<th className="border border-border px-3 py-2 text-left">Categoria</th>
<th className="border border-border px-3 py-2 text-left">Raridade</th>
<th className="border border-border px-3 py-2 text-left">Origem</th>
<th className="border border-border px-3 py-2 text-left">Servidores</th>
<th className="border border-border px-3 py-2 text-left">Atualizado em</th>
<th className="border border-border px-3 py-2 text-left">Ações</th>
<th className="border border-border px-3 py-2 text-left">
Imagem
</th>
<th className="border border-border px-3 py-2 text-left">
Nome
</th>
<th className="border border-border px-3 py-2 text-left">
Categoria
</th>
<th className="border border-border px-3 py-2 text-left">
Raridade
</th>
<th className="border border-border px-3 py-2 text-left">
Origem
</th>
<th className="border border-border px-3 py-2 text-left">
Servidores
</th>
<th className="border border-border px-3 py-2 text-left">
Atualizado em
</th>
<th className="border border-border px-3 py-2 text-left">
Ações
</th>
</tr>
</thead>
<tbody>
{itemsForDisplay.length === 0 ? (
<tr>
<td colSpan={8} className="border border-border px-3 py-6 text-center text-sm text-muted-foreground">
<td
colSpan={8}
className="border border-border px-3 py-6 text-center text-sm text-muted-foreground"
>
Nenhum item encontrado com os filtros atuais.
</td>
</tr>
) : (
itemsForDisplay.map((item) => (
<tr key={item.item_id} className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors">
<tr
key={item.item_id}
className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors"
>
<td className="border border-border px-3 py-2 align-middle">
{item.image_url ? (
<div className="relative h-12 w-12 overflow-hidden rounded border">
@@ -413,7 +525,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
/>
</div>
) : (
<span className="text-xs text-muted-foreground">Sem imagem</span>
<span className="text-xs text-muted-foreground">
Sem imagem
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top">
@@ -422,14 +536,21 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
#{item.item_id}
</div>
</td>
<td className="border border-border px-3 py-2 align-top text-sm">{item.category}</td>
<td className="border border-border px-3 py-2 align-top text-sm">
{item.category}
</td>
<td className="border border-border px-3 py-2 align-top text-sm">
{item.rarity ? (
<Badge variant="outline" className="uppercase text-[10px]">
<Badge
variant="outline"
className="uppercase text-[10px]"
>
{item.rarity}
</Badge>
) : (
<span className="text-muted-foreground text-xs">-</span>
<span className="text-muted-foreground text-xs">
-
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground">
@@ -440,17 +561,27 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{item.servers.length > 0 ? (
<div className="flex flex-wrap gap-1">
{item.servers.map((serverTag) => (
<Badge key={`${item.item_id}-${serverTag}`} variant="secondary" className="text-[10px] uppercase">
<Badge
key={`${item.item_id}-${serverTag}`}
variant="secondary"
className="text-[10px] uppercase"
>
{serverTag}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-xs">Global</span>
<span className="text-muted-foreground text-xs">
Global
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground">
{format(new Date(item.updated_at), "dd/MM/yyyy HH:mm", { locale: ptBR })}
{format(
new Date(item.updated_at),
"dd/MM/yyyy HH:mm",
{ locale: ptBR },
)}
</td>
<td className="border border-border px-3 py-2 align-top">
<Button
@@ -471,7 +602,8 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Página {page} • Total de {Math.max(1, Math.ceil(data.total / pageSize))}
Página {page} • Total de{" "}
{Math.max(1, Math.ceil(data.total / pageSize))}
</span>
<div className="flex items-center gap-2">
<Button
@@ -485,8 +617,16 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<Button
variant="outline"
size="sm"
onClick={() => setPage((prev) => (prev >= Math.ceil(data.total / pageSize) ? prev : prev + 1))}
disabled={page >= Math.ceil(data.total / pageSize) || loading}
onClick={() =>
setPage((prev) =>
prev >= Math.ceil(data.total / pageSize)
? prev
: prev + 1,
)
}
disabled={
page >= Math.ceil(data.total / pageSize) || loading
}
>
Próxima
</Button>
@@ -510,4 +650,3 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
</div>
);
}
+99 -33
View File
@@ -1,13 +1,19 @@
"use client";
import { Loader2, Sparkles } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ItemImageCapture, ProcessedImage } from "./ItemImageCapture";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ItemImageCapture, type ProcessedImage } from "./ItemImageCapture";
interface ItemAiAssistantProps {
locale: string;
@@ -86,7 +92,10 @@ export function ItemAiAssistant({
const data = await response.json();
const structure = data?.structure ?? data;
const columns = structure?.columns;
setLogs((prev) => [...prev, `Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`]);
setLogs((prev) => [
...prev,
`Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`,
]);
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
@@ -96,7 +105,15 @@ export function ItemAiAssistant({
.replace(/\.[a-zA-Z0-9]+$/, "")
.toLowerCase()
.replace(/[_\s]+/g, "-");
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
const common = new Set([
"image",
"img",
"screenshot",
"captura",
"photo",
"foto",
"unknown",
]);
const orientation = first.height > first.width ? "retrato" : "paisagem";
const suggestions: string[] = [];
if (base && !common.has(base)) suggestions.push(base);
@@ -105,12 +122,19 @@ export function ItemAiAssistant({
return Array.from(new Set(suggestions)).slice(0, 3);
};
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim());
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim());
const autoNameDetected =
typeof columns?.name === "string" &&
Boolean(String(columns.name).trim());
const autoDescDetected =
typeof columns?.description === "string" &&
Boolean(String(columns.description).trim());
const origemDados: string = autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
const origemDados: string =
autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
const sugestoesNome = !autoNameDetected
? suggestNamesFromImages(images)
: [];
// Estrutura final com campos obrigatórios, origem e sugestões
const finalStructure = {
@@ -125,18 +149,35 @@ export function ItemAiAssistant({
? "Confirme o nome sugerido ou ajuste se necessário."
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
},
sugestoes: sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
sugestoes:
sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
},
};
setRawJson(JSON.stringify(finalStructure, null, 2));
const prefill: ItemAiPrefill = {
name: autoNameDetected ? (columns!.name as string) : undefined,
description: autoDescDetected ? (columns!.description as string) : undefined,
metadata: typeof columns === "object" && columns !== null
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined }
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined },
name: autoNameDetected ? (columns?.name as string) : undefined,
description: autoDescDetected
? (columns?.description as string)
: undefined,
metadata:
typeof columns === "object" && columns !== null
? {
...columns,
origem_dados: origemDados,
confirmar_nome: autoNameDetected ? false : undefined,
sugestoes: sugestoesNome.length
? { nome: sugestoesNome }
: undefined,
}
: {
origem_dados: origemDados,
confirmar_nome: autoNameDetected ? false : undefined,
sugestoes: sugestoesNome.length
? { nome: sugestoesNome }
: undefined,
},
};
if (columns) {
@@ -160,12 +201,20 @@ export function ItemAiAssistant({
}
}
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
setLogs((prev) => [
...prev,
"Sugestões de preenchimento geradas. Aguardando confirmação do usuário.",
]);
onGenerated({ json: finalStructure, prefill });
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]);
setError(
err instanceof Error ? err.message : "Erro ao processar análise de IA.",
);
setLogs((prev) => [
...prev,
`Erro: ${err instanceof Error ? err.message : String(err)}`,
]);
} finally {
setLoading(false);
setLogs((prev) => [...prev, "Processo de geração concluído."]);
@@ -193,8 +242,8 @@ export function ItemAiAssistant({
Assistente IA para registro de item
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e metadados. Você pode ajustar os dados
antes de salvar.
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e
metadados. Você pode ajustar os dados antes de salvar.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -216,7 +265,10 @@ export function ItemAiAssistant({
<div className="space-y-2">
<label className="text-sm font-medium">Escopo</label>
<Select value={scope} onValueChange={(value: "global" | "server") => setScope(value)}>
<Select
value={scope}
onValueChange={(value: "global" | "server") => setScope(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -247,7 +299,9 @@ export function ItemAiAssistant({
)}
<div className="space-y-2">
<label className="text-sm font-medium">Descrição detalhada para a IA</label>
<label className="text-sm font-medium">
Descrição detalhada para a IA
</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
@@ -257,10 +311,14 @@ export function ItemAiAssistant({
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Prints / evidências visuais</label>
<label className="text-sm font-medium">
Prints / evidências visuais
</label>
<ItemImageCapture onImagesChange={setImages} />
{images.length > 0 && (
<p className="text-xs text-emerald-600">Imagem recebida — pronta para processamento.</p>
<p className="text-xs text-emerald-600">
Imagem recebida — pronta para processamento.
</p>
)}
</div>
@@ -271,12 +329,17 @@ export function ItemAiAssistant({
)}
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleGenerate} disabled={disabled || loading}>
<Button
type="button"
onClick={handleGenerate}
disabled={disabled || loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gerar com IA
</Button>
<p className="text-xs text-muted-foreground">
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
A IA retornará um JSON com sugestões de campos. As alterações só
serão aplicadas após sua confirmação.
</p>
</div>
</div>
@@ -292,16 +355,20 @@ export function ItemAiAssistant({
</pre>
) : (
<p>
Execute a análise para visualizar o JSON sugerido. Ele será usado para preencher automaticamente os
campos do formulário.
Execute a análise para visualizar o JSON sugerido. Ele será
usado para preencher automaticamente os campos do formulário.
</p>
)}
{logs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
<p className="text-[11px] text-muted-foreground">
Logs do processo
</p>
<div className="rounded-md border bg-muted/30 p-2">
{logs.map((line, idx) => (
<p key={idx} className="text-[11px] leading-relaxed">• {line}</p>
<p key={idx} className="text-[11px] leading-relaxed">
• {line}
</p>
))}
</div>
</div>
@@ -312,4 +379,3 @@ export function ItemAiAssistant({
</section>
);
}
@@ -41,3 +41,5 @@ export function ItemCreationModal({
);
}
// Reexporta o tipo para permitir importação de ItemFormPayload a partir de ItemCreationModal
export type { ItemFormPayload } from "./AdminItemForm";
+1 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { Upload, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { resizeImageToDataURL } from "@/lib/image/resize";
import { cn } from "@/lib/utils";
@@ -225,4 +225,3 @@ export function ItemImageCapture({
</div>
);
}
+108 -37
View File
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
type FeedbackState =
| { type: "success"; message: string }
@@ -38,7 +38,10 @@ function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState {
priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "",
isActive: tier.is_active,
features: (tier.features ?? [])
.map((feature) => `${feature.feature_key}|${feature.label}|${feature.description ?? ""}`)
.map(
(feature) =>
`${feature.feature_key}|${feature.label}|${feature.description ?? ""}`,
)
.join("\n"),
};
}
@@ -53,7 +56,9 @@ function parseFeatures(text: string) {
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [featureKey, label, description] = line.split("|").map((part) => part?.trim() ?? "");
const [featureKey, label, description] = line
.split("|")
.map((part) => part?.trim() ?? "");
return {
feature_key: featureKey,
label: label || featureKey,
@@ -79,8 +84,14 @@ export function AdminSubscriptionDashboard({
isActive: true,
features: "",
});
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(() =>
Object.fromEntries(initialTiers.map((tier) => [tier.subscription_tier_id, mapTierToFormState(tier)]))
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(
() =>
Object.fromEntries(
initialTiers.map((tier) => [
tier.subscription_tier_id,
mapTierToFormState(tier),
]),
),
);
const [assignUserId, setAssignUserId] = useState("");
const [assignTierId, setAssignTierId] = useState<string>("free");
@@ -105,8 +116,8 @@ export function AdminSubscriptionDashboard({
data.tiers.map((tier: SubscriptionTierSummary) => [
tier.subscription_tier_id,
mapTierToFormState(tier),
])
)
]),
),
);
}, []);
@@ -119,8 +130,12 @@ export function AdminSubscriptionDashboard({
slug: createForm.slug,
description: createForm.description || null,
level: Number.parseInt(createForm.level.toString(), 10),
priceMonthly: createForm.priceMonthly ? Number.parseFloat(createForm.priceMonthly) : null,
priceYearly: createForm.priceYearly ? Number.parseFloat(createForm.priceYearly) : null,
priceMonthly: createForm.priceMonthly
? Number.parseFloat(createForm.priceMonthly)
: null,
priceYearly: createForm.priceYearly
? Number.parseFloat(createForm.priceYearly)
: null,
isActive: createForm.isActive,
features: parseFeatures(createForm.features),
};
@@ -156,7 +171,9 @@ export function AdminSubscriptionDashboard({
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível criar o plano.",
error instanceof Error
? error.message
: "Não foi possível criar o plano.",
});
} finally {
setIsLoading(false);
@@ -173,8 +190,12 @@ export function AdminSubscriptionDashboard({
slug: form.slug,
description: form.description || null,
level: Number.parseInt(form.level.toString(), 10),
priceMonthly: form.priceMonthly ? Number.parseFloat(form.priceMonthly) : null,
priceYearly: form.priceYearly ? Number.parseFloat(form.priceYearly) : null,
priceMonthly: form.priceMonthly
? Number.parseFloat(form.priceMonthly)
: null,
priceYearly: form.priceYearly
? Number.parseFloat(form.priceYearly)
: null,
isActive: form.isActive,
features: parseFeatures(form.features),
};
@@ -194,12 +215,17 @@ export function AdminSubscriptionDashboard({
}
await refreshTiers();
setFeedback({ type: "success", message: "Plano atualizado com sucesso." });
setFeedback({
type: "success",
message: "Plano atualizado com sucesso.",
});
} catch (error) {
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível atualizar o plano.",
error instanceof Error
? error.message
: "Não foi possível atualizar o plano.",
});
} finally {
setIsLoading(false);
@@ -231,7 +257,9 @@ export function AdminSubscriptionDashboard({
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível remover o plano.",
error instanceof Error
? error.message
: "Não foi possível remover o plano.",
});
} finally {
setIsLoading(false);
@@ -242,7 +270,10 @@ export function AdminSubscriptionDashboard({
resetFeedback();
if (!assignUserId.trim()) {
setFeedback({ type: "error", message: "Informe um ID de usuário válido." });
setFeedback({
type: "error",
message: "Informe um ID de usuário válido.",
});
return;
}
@@ -290,7 +321,7 @@ export function AdminSubscriptionDashboard({
}
return a.level - b.level;
}),
[tiers]
[tiers],
);
return (
@@ -322,7 +353,10 @@ export function AdminSubscriptionDashboard({
placeholder="Ex.: Ultra"
value={createForm.name}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, name: event.target.value }))
setCreateForm((prev) => ({
...prev,
name: event.target.value,
}))
}
/>
</div>
@@ -335,7 +369,10 @@ export function AdminSubscriptionDashboard({
placeholder="ex.: ultra"
value={createForm.slug}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, slug: event.target.value }))
setCreateForm((prev) => ({
...prev,
slug: event.target.value,
}))
}
/>
</div>
@@ -365,7 +402,10 @@ export function AdminSubscriptionDashboard({
variant={createForm.isActive ? "default" : "outline"}
type="button"
onClick={() =>
setCreateForm((prev) => ({ ...prev, isActive: !prev.isActive }))
setCreateForm((prev) => ({
...prev,
isActive: !prev.isActive,
}))
}
>
{createForm.isActive ? "Ativo" : "Inativo"}
@@ -375,7 +415,10 @@ export function AdminSubscriptionDashboard({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-price-monthly">
<label
className="text-sm font-medium"
htmlFor="tier-price-monthly"
>
Preço mensal (opcional)
</label>
<Input
@@ -385,12 +428,18 @@ export function AdminSubscriptionDashboard({
step="0.01"
value={createForm.priceMonthly}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceMonthly: event.target.value }))
setCreateForm((prev) => ({
...prev,
priceMonthly: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-price-yearly">
<label
className="text-sm font-medium"
htmlFor="tier-price-yearly"
>
Preço anual (opcional)
</label>
<Input
@@ -400,7 +449,10 @@ export function AdminSubscriptionDashboard({
step="0.01"
value={createForm.priceYearly}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceYearly: event.target.value }))
setCreateForm((prev) => ({
...prev,
priceYearly: event.target.value,
}))
}
/>
</div>
@@ -415,7 +467,10 @@ export function AdminSubscriptionDashboard({
rows={3}
value={createForm.description}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, description: event.target.value }))
setCreateForm((prev) => ({
...prev,
description: event.target.value,
}))
}
/>
</div>
@@ -429,7 +484,10 @@ export function AdminSubscriptionDashboard({
rows={4}
value={createForm.features}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, features: event.target.value }))
setCreateForm((prev) => ({
...prev,
features: event.target.value,
}))
}
placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
/>
@@ -452,7 +510,8 @@ export function AdminSubscriptionDashboard({
</CardHeader>
<CardContent className="space-y-6">
{orderedTiers.map((tier) => {
const formState = editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
const formState =
editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
return (
<Card key={tier.subscription_tier_id} className="border-muted">
@@ -461,7 +520,10 @@ export function AdminSubscriptionDashboard({
<CardTitle className="flex items-center gap-2 text-xl">
{tier.name}
<Badge variant="secondary">{tier.slug}</Badge>
<Badge variant={tier.is_active ? "default" : "outline"} className="uppercase">
<Badge
variant={tier.is_active ? "default" : "outline"}
className="uppercase"
>
{tier.is_active ? "Ativo" : "Inativo"}
</Badge>
</CardTitle>
@@ -514,7 +576,10 @@ export function AdminSubscriptionDashboard({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
level: Number.parseInt(event.target.value || "0", 10),
level: Number.parseInt(
event.target.value || "0",
10,
),
},
}))
}
@@ -530,7 +595,8 @@ export function AdminSubscriptionDashboard({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
isActive: !prev[tier.subscription_tier_id].isActive,
isActive:
!prev[tier.subscription_tier_id].isActive,
},
}))
}
@@ -542,7 +608,9 @@ export function AdminSubscriptionDashboard({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Preço mensal</label>
<label className="text-sm font-medium">
Preço mensal
</label>
<Input
type="number"
min={0}
@@ -618,7 +686,9 @@ export function AdminSubscriptionDashboard({
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={() => handleUpdateTier(tier.subscription_tier_id)}
onClick={() =>
handleUpdateTier(tier.subscription_tier_id)
}
disabled={isLoading}
>
Salvar alterações
@@ -640,7 +710,9 @@ export function AdminSubscriptionDashboard({
<Button
type="button"
variant="destructive"
onClick={() => handleDeleteTier(tier.subscription_tier_id, tier.slug)}
onClick={() =>
handleDeleteTier(tier.subscription_tier_id, tier.slug)
}
disabled={isLoading}
>
Remover plano
@@ -654,7 +726,7 @@ export function AdminSubscriptionDashboard({
</CardContent>
</Card>
<Card>
<Card>
<CardHeader>
<CardTitle>Vincular plano a usuário</CardTitle>
</CardHeader>
@@ -698,4 +770,3 @@ export function AdminSubscriptionDashboard({
</div>
);
}
+2 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,7 +9,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useSession } from "@/lib/hooks/use-session";
@@ -58,7 +58,7 @@ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
} else {
setError(data.error || "Erro ao fazer login");
}
} catch (err) {
} catch (_err) {
setError("Erro ao conectar com o servidor");
} finally {
setIsLoading(false);
@@ -116,4 +116,3 @@ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
</Dialog>
);
}
+101 -37
View File
@@ -1,6 +1,10 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { ChevronDown, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,22 +12,29 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useSession } from "@/lib/hooks/use-session";
import { useServers } from "@/lib/hooks/use-servers";
import { useTranslations } from "next-intl";
import { formatPhone, formatInternationalPhone, unformatPhone, combinePhoneWithDialCode, getPhonePlaceholder, checkPasswordStrength, validatePasswordRequirements } from "@/lib/utils/formatters";
import { COUNTRIES, DEFAULT_COUNTRY, type Country } from "@/lib/utils/countries";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { X, Plus, Check, XCircle, ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServers } from "@/lib/hooks/use-servers";
import { useSession } from "@/lib/hooks/use-session";
import {
COUNTRIES,
type Country,
DEFAULT_COUNTRY,
} from "@/lib/utils/countries";
import {
checkPasswordStrength,
combinePhoneWithDialCode,
formatInternationalPhone,
getPhonePlaceholder,
unformatPhone,
validatePasswordRequirements,
} from "@/lib/utils/formatters";
interface RegisterDialogProps {
open: boolean;
@@ -39,7 +50,11 @@ interface ServerSelection {
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const t = useTranslations("auth.register");
const { refreshSession } = useSession();
const { servers, isLoading: serversLoading, error: serversError } = useServers();
const {
servers,
isLoading: serversLoading,
error: serversError,
} = useServers();
// Form fields
const [username, setUsername] = useState("");
@@ -49,7 +64,8 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [selectedCountry, setSelectedCountry] = useState<Country>(DEFAULT_COUNTRY);
const [selectedCountry, setSelectedCountry] =
useState<Country>(DEFAULT_COUNTRY);
const [countrySearch, setCountrySearch] = useState("");
// Servers selection
@@ -61,7 +77,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [passwordStrength, setPasswordStrength] = useState<"weak" | "medium" | "strong">("weak");
const [passwordStrength, setPasswordStrength] = useState<
"weak" | "medium" | "strong"
>("weak");
// Filtered servers based on search
const filteredServers = useMemo(() => {
@@ -70,7 +88,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
return servers.filter(
(s) =>
s.serverName.toLowerCase().includes(searchLower) &&
!selectedServers.some((sel) => sel.serverName === s.serverName)
!selectedServers.some((sel) => sel.serverName === s.serverName),
);
}, [serverSearch, servers, selectedServers]);
@@ -145,7 +163,11 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
};
const handlePhoneChange = (value: string) => {
const formatted = formatInternationalPhone(value, selectedCountry.dialCode, selectedCountry.code);
const formatted = formatInternationalPhone(
value,
selectedCountry.dialCode,
selectedCountry.code,
);
setPhone(formatted);
};
@@ -157,7 +179,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
(c) =>
c.name.toLowerCase().includes(searchLower) ||
c.dialCode.includes(searchLower) ||
c.code.toLowerCase().includes(searchLower)
c.code.toLowerCase().includes(searchLower),
);
}, [countrySearch]);
@@ -233,7 +255,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone ? combinePhoneWithDialCode(selectedCountry.dialCode, phone) : undefined,
phone: phone
? combinePhoneWithDialCode(selectedCountry.dialCode, phone)
: undefined,
servers: selectedServers.map((s) => ({
serverName: s.serverName,
characterName: s.characterName.trim(),
@@ -246,7 +270,10 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
console.error("Resposta não é JSON do registro:", text.substring(0, 200));
console.error(
"Resposta não é JSON do registro:",
text.substring(0, 200),
);
setError("Erro: resposta inválida do servidor");
setIsLoading(false);
return;
@@ -270,7 +297,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
setError(data.error || "Erro ao criar conta");
}
}
} catch (err) {
} catch (_err) {
setError("Erro ao conectar com o servidor");
} finally {
setIsLoading(false);
@@ -313,7 +340,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
className={fieldErrors.username ? "border-destructive" : ""}
/>
{fieldErrors.username && (
<p className="text-sm text-destructive">{fieldErrors.username}</p>
<p className="text-sm text-destructive">
{fieldErrors.username}
</p>
)}
</div>
@@ -351,7 +380,12 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
<div
className={`h-full ${getPasswordStrengthColor()} transition-all`}
style={{
width: passwordStrength === "weak" ? "33%" : passwordStrength === "medium" ? "66%" : "100%",
width:
passwordStrength === "weak"
? "33%"
: passwordStrength === "medium"
? "66%"
: "100%",
}}
/>
</div>
@@ -362,7 +396,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</div>
)}
{fieldErrors.password && (
<p className="text-sm text-destructive">{fieldErrors.password}</p>
<p className="text-sm text-destructive">
{fieldErrors.password}
</p>
)}
</div>
@@ -375,10 +411,14 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className={fieldErrors.confirmPassword ? "border-destructive" : ""}
className={
fieldErrors.confirmPassword ? "border-destructive" : ""
}
/>
{fieldErrors.confirmPassword && (
<p className="text-sm text-destructive">{fieldErrors.confirmPassword}</p>
<p className="text-sm text-destructive">
{fieldErrors.confirmPassword}
</p>
)}
</div>
</div>
@@ -400,7 +440,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
className={fieldErrors.firstName ? "border-destructive" : ""}
/>
{fieldErrors.firstName && (
<p className="text-sm text-destructive">{fieldErrors.firstName}</p>
<p className="text-sm text-destructive">
{fieldErrors.firstName}
</p>
)}
</div>
@@ -416,7 +458,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
className={fieldErrors.lastName ? "border-destructive" : ""}
/>
{fieldErrors.lastName && (
<p className="text-sm text-destructive">{fieldErrors.lastName}</p>
<p className="text-sm text-destructive">
{fieldErrors.lastName}
</p>
)}
</div>
</div>
@@ -435,12 +479,17 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
>
<span className="flex items-center gap-2">
<span>{selectedCountry.flag}</span>
<span className="text-sm">{selectedCountry.dialCode}</span>
<span className="text-sm">
{selectedCountry.dialCode}
</span>
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] max-h-[300px] overflow-y-auto">
<DropdownMenuContent
align="start"
className="w-[300px] max-h-[300px] overflow-y-auto"
>
<div className="p-2">
<Input
type="text"
@@ -463,7 +512,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const reformatted = formatInternationalPhone(
unformatted,
country.dialCode,
country.code
country.code,
);
setPhone(reformatted);
}
@@ -473,7 +522,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
<span className="flex items-center gap-2 w-full">
<span>{country.flag}</span>
<span className="flex-1">{country.name}</span>
<span className="text-muted-foreground text-sm">{country.dialCode}</span>
<span className="text-muted-foreground text-sm">
{country.dialCode}
</span>
</span>
</DropdownMenuItem>
))}
@@ -504,7 +555,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">{t("servers")}</h3>
<p className="text-xs text-muted-foreground">{t("serversDescription")}</p>
<p className="text-xs text-muted-foreground">
{t("serversDescription")}
</p>
</div>
</div>
@@ -587,15 +640,23 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`character-${index}`}>{t("characterName")}</Label>
<Label htmlFor={`character-${index}`}>
{t("characterName")}
</Label>
<Input
id={`character-${index}`}
type="text"
placeholder={t("characterNamePlaceholder")}
value={server.characterName}
onChange={(e) => updateServerCharacter(index, e.target.value)}
onChange={(e) =>
updateServerCharacter(index, e.target.value)
}
disabled={isLoading}
className={fieldErrors[`server_${index}`] ? "border-destructive" : ""}
className={
fieldErrors[`server_${index}`]
? "border-destructive"
: ""
}
/>
{fieldErrors[`server_${index}`] && (
<p className="text-sm text-destructive">
@@ -624,7 +685,11 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || serversLoading}>
<Button
type="submit"
className="w-full"
disabled={isLoading || serversLoading}
>
{isLoading ? t("submitting") : t("submit")}
</Button>
</form>
@@ -632,4 +697,3 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</Dialog>
);
}
+15 -13
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { Package, MapPin, DollarSign, Coins, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Coins, DollarSign, MapPin, Package, User } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { Chat } from "@/lib/hooks/use-chat";
interface ChatHeaderProps {
@@ -17,20 +17,23 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
const [imageError, setImageError] = useState(false);
const formatPrice = (price: number | null | undefined, type: "pix" | "tc" | "gp"): string => {
const formatPrice = (
price: number | null | undefined,
type: "pix" | "tc" | "gp",
): string => {
if (price === null || price === undefined) return "N/A";
if (type === "pix") {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(price);
}
if (type === "tc") {
return `${price.toLocaleString("pt-BR")} TC`;
}
if (type === "gp") {
const gold = price / 1000000;
if (gold >= 1) {
@@ -38,7 +41,7 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
}
return `${price.toLocaleString("pt-BR")} gp`;
}
return price.toLocaleString("pt-BR");
};
@@ -63,7 +66,9 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
{/* Detalhes do anúncio */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
<h3 className="font-semibold text-lg truncate">
{listing.item_name}
</h3>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
@@ -102,9 +107,7 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
{/* Informações do outro usuário */}
<div className="flex items-center gap-2 pt-2 border-t">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Conversando com:{" "}
</span>
<span className="text-sm text-muted-foreground">Conversando com: </span>
<span className="text-sm font-medium">
{otherUser.character_name || otherUser.username}
</span>
@@ -112,4 +115,3 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
</div>
);
}
+6 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChatHeader } from "./ChatHeader";
import { MessageList } from "./MessageList";
import { MessageInput } from "./MessageInput";
import { Skeleton } from "@/components/ui/skeleton";
import { useChat } from "@/lib/hooks/use-chat";
import { useSession } from "@/lib/hooks/use-session";
import { Skeleton } from "@/components/ui/skeleton";
import { ChatHeader } from "./ChatHeader";
import { MessageInput } from "./MessageInput";
import { MessageList } from "./MessageList";
interface ChatModalProps {
open: boolean;
@@ -104,9 +104,7 @@ export function ChatModal({
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="sr-only">
<DialogTitle>Chat com vendedor</DialogTitle>
<DialogDescription>
Conversa sobre o anúncio
</DialogDescription>
<DialogDescription>Conversa sobre o anúncio</DialogDescription>
</DialogHeader>
{isCreatingChat || (isLoading && !chat) ? (
@@ -144,4 +142,3 @@ export function ChatModal({
</Dialog>
);
}
+5 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/lib/hooks/use-chat";
import { cn } from "@/lib/utils";
interface MessageBubbleProps {
message: ChatMessage;
@@ -31,7 +31,7 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
<div
className={cn(
"flex w-full mb-2",
isOwn ? "justify-end" : "justify-start"
isOwn ? "justify-end" : "justify-start",
)}
>
<div
@@ -39,7 +39,7 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
"max-w-[70%] rounded-lg px-4 py-2",
isOwn
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
: "bg-muted text-foreground",
)}
>
<p className="text-sm whitespace-pre-wrap break-words">
@@ -48,16 +48,13 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
<div
className={cn(
"flex items-center gap-1 mt-1 text-xs",
isOwn ? "text-primary-foreground/70" : "text-muted-foreground"
isOwn ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
<span>{formatTime(message.created_at)}</span>
{isOwn && message.read_at && (
<span className="opacity-70">✓✓</span>
)}
{isOwn && message.read_at && <span className="opacity-70">✓✓</span>}
</div>
</div>
</div>
);
}
+4 -5
View File
@@ -1,9 +1,9 @@
"use client";
import { useState, KeyboardEvent } from "react";
import { Loader2, Send } from "lucide-react";
import { type KeyboardEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Loader2 } from "lucide-react";
import { useChatTyping } from "@/lib/hooks/use-chat";
import { useSocket } from "@/lib/hooks/use-socket";
@@ -25,7 +25,7 @@ export function MessageInput({
const { handleTypingStart, handleTypingStop } = useChatTyping(
socket,
isConnected,
chatId
chatId,
);
const handleSubmit = async () => {
@@ -46,7 +46,7 @@ export function MessageInput({
const handleChange = (value: string) => {
setContent(value);
if (value.trim().length > 0) {
handleTypingStart();
} else {
@@ -85,4 +85,3 @@ export function MessageInput({
</div>
);
}
+6 -5
View File
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import { MessageBubble } from "./MessageBubble";
import { Skeleton } from "@/components/ui/skeleton";
import type { ChatMessage } from "@/lib/hooks/use-chat";
import { MessageBubble } from "./MessageBubble";
interface MessageListProps {
messages: ChatMessage[];
@@ -40,7 +40,7 @@ export function MessageList({
if (messages.length > 0 && !isLoading) {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
}
}, [isLoading]);
}, [isLoading, messages.length]);
// Detectar scroll no topo para carregar mais
const handleScroll = () => {
@@ -50,7 +50,7 @@ export function MessageList({
if (container.scrollTop === 0) {
const previousScrollHeight = container.scrollHeight;
onLoadMore();
// Ajustar scroll após carregar mais mensagens
setTimeout(() => {
const newScrollHeight = container.scrollHeight;
@@ -75,7 +75,9 @@ export function MessageList({
<div className="flex items-center justify-center h-full text-center text-muted-foreground">
<div>
<p className="text-sm">Nenhuma mensagem ainda</p>
<p className="text-xs mt-1">Envie a primeira mensagem para começar a conversa</p>
<p className="text-xs mt-1">
Envie a primeira mensagem para começar a conversa
</p>
</div>
</div>
) : (
@@ -115,4 +117,3 @@ export function MessageList({
</div>
);
}
+26 -27
View File
@@ -1,32 +1,31 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import confetti from "canvas-confetti";
import {
Heart,
Coffee,
Pizza,
Loader2,
Copy,
Check,
Lock,
User,
CheckCircle,
CheckCircle2,
DollarSign,
Coffee,
Copy,
Heart,
Loader2,
Lock,
Pizza,
User,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DonationTier, DonationResponse } from "@/types/donation";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { DonationResponse, DonationTier } from "@/types/donation";
interface DonationCardProps extends DonationTier {}
@@ -61,7 +60,7 @@ export function DonationCard({
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
const [_paymentConfirmed, setPaymentConfirmed] = useState(false);
const [showThankYouModal, setShowThankYouModal] = useState(false);
const t = useTranslations("donation");
@@ -94,7 +93,7 @@ export function DonationCard({
const updateTimer = () => {
try {
const expires = new Date(pixData!.expiresAt!);
const expires = new Date(pixData?.expiresAt!);
const now = new Date();
const diff = expires.getTime() - now.getTime();
@@ -118,7 +117,7 @@ export function DonationCard({
try {
const response = await fetch(
`/api/donations/status?pixId=${pixData.pixId}`
`/api/donations/status?pixId=${pixData.pixId}`,
);
const data = await response.json();
@@ -346,7 +345,7 @@ export function DonationCard({
const isValidCustomAmount = () => {
const amount = parseFloat(customAmount);
return (
!isNaN(amount) &&
!Number.isNaN(amount) &&
amount >= customProduct.minAmount &&
amount <= customProduct.maxAmount
);
@@ -363,12 +362,12 @@ export function DonationCard({
// Garante apenas um separador decimal
const parts = normalized.split(".");
if (parts.length > 2) {
return parts[0] + "." + parts.slice(1).join("");
return `${parts[0]}.${parts.slice(1).join("")}`;
}
// Limita 2 casas decimais
if (parts[1]?.length > 2) {
return parts[0] + "." + parts[1].substring(0, 2);
return `${parts[0]}.${parts[1].substring(0, 2)}`;
}
return normalized;
@@ -396,12 +395,12 @@ export function DonationCard({
setError("Digite um valor");
return;
}
const amount = parseFloat(customAmount);
const _amount = parseFloat(customAmount);
if (!isValidCustomAmount()) {
setError(
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
2
)} e R$ ${customProduct.maxAmount.toFixed(2)}`
2,
)} e R$ ${customProduct.maxAmount.toFixed(2)}`,
);
return;
}
@@ -463,10 +462,10 @@ export function DonationCard({
<p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount
? `${t(
"form.minAmount"
"form.minAmount",
)} ${customProduct.minAmount.toFixed(2)}`
: `${t(
"form.maxAmount"
"form.maxAmount",
)} ${customProduct.maxAmount.toFixed(2)}`}
</p>
)}
@@ -485,7 +484,7 @@ export function DonationCard({
</>
) : customAmount ? (
`${t("form.donate")} ${parseFloat(
customAmount || "0"
customAmount || "0",
).toFixed(2)}`
) : (
t("form.chooseAmount")
+4 -4
View File
@@ -1,10 +1,10 @@
"use client";
import { Button } from "@/components/ui/button";
import { Heart, Coffee } from "lucide-react";
import { BlurFade } from "@/components/ui/blur-fade";
import { useTranslations } from "next-intl";
import { Coffee, Heart } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
export function DonationFooterCard() {
const t = useTranslations("donation");
+173 -147
View File
@@ -1,45 +1,39 @@
"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, useLocale } from "next-intl";
import {
TrendingUp,
TrendingDown,
Sword,
Shield,
Weight,
Heart,
Star,
ArrowDown,
ArrowUp,
Boxes,
Clock,
Coins,
ArrowUp,
ArrowDown,
Minus,
Zap,
Flame,
Snowflake,
Zap as Energy,
Skull,
Cross,
Badge as BadgeIcon,
Boxes,
Gem,
FlaskConical,
Wrench,
ScrollText,
Leaf,
Zap as Energy,
Flame,
FlaskConical,
Gem,
Heart,
Leaf,
Minus,
ScrollText,
Shield,
Skull,
Snowflake,
Star,
Sword,
TrendingDown,
TrendingUp,
Weight,
Wrench,
Zap,
} from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import {
getRarityColor,
getRarityBgColor,
getRarityGlow,
} from "@/lib/currency-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MagicCard } from "@/components/ui/magic-card";
import { PriceDisplay } from "@/components/ui/price-display";
import { formatNumber } from "@/lib/utils/formatters";
import type { TibiaItem } from "@/types/item";
interface ItemCardProps {
item: TibiaItem;
@@ -50,7 +44,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item");
const locale = useLocale();
const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [_isHovered, setIsHovered] = useState(false);
const formatVocation = (v: string | undefined): string | undefined => {
if (!v) return undefined;
let out = String(v);
@@ -64,14 +58,22 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
return out;
};
const ScrollRow = ({ children, ariaLabel }: { children: React.ReactNode; ariaLabel: string }) => {
const ScrollRow = ({
children,
ariaLabel,
}: {
children: React.ReactNode;
ariaLabel: string;
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [_canScrollLeft, setCanScrollLeft] = useState(false);
const [_canScrollRight, setCanScrollRight] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const autoScrollRef = useRef<number | null>(null);
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>({ active: false, startX: 0, startLeft: 0 });
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>(
{ active: false, startX: 0, startLeft: 0 },
);
const [isDragging, setIsDragging] = useState(false);
const updateButtons = () => {
@@ -114,9 +116,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
el.removeEventListener("mouseleave", onLeave);
ro.disconnect();
};
}, []);
}, [updateButtons]);
const scrollBy = (dx: number) => {
const _scrollBy = (dx: number) => {
const el = scrollerRef.current;
if (!el) return;
const base = contentRef.current?.offsetWidth || 0;
@@ -182,7 +184,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
if (!drag.current.active) return;
drag.current.active = false;
setIsDragging(false);
try { el.releasePointerCapture(e.pointerId); } catch {}
try {
el.releasePointerCapture(e.pointerId);
} catch {}
};
el.addEventListener("pointerdown", onPointerDown);
@@ -204,7 +208,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
ref={scrollerRef}
className={`overflow-x-auto no-scrollbar whitespace-nowrap pr-6 pl-6 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
style={{ scrollBehavior: "smooth" }}
>
>
<div ref={contentRef} className="inline-flex items-center gap-2">
{children}
{children}
@@ -213,10 +217,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div>
);
};
const [showAllDroppers, setShowAllDroppers] = useState(false);
const rarityColors = {
const [_showAllDroppers, _setShowAllDroppers] = useState(false);
const _rarityColors = {
common: "bg-gray-500",
uncommon: "bg-green-500",
rare: "bg-blue-500",
@@ -251,14 +255,20 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
(item as any).price.sell !== 0;
const priceDifference = hasPrice
? (((item as any).price.buy - (item as any).price.sell) /
(item as any).price.sell) * 100
(item as any).price.sell) *
100
: 0;
const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20%
// Determinar tendência de preço
const getPriceTrend = () => {
const ph = (item as any).priceHistory;
if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") {
if (
!ph ||
typeof ph.highest !== "number" ||
typeof ph.lowest !== "number" ||
typeof ph.average !== "number"
) {
return "stable";
}
if (ph.highest > ph.average * 1.1) return "up";
@@ -273,8 +283,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
trend === "up"
? "text-green-500"
: trend === "down"
? "text-red-500"
: "text-gray-500";
? "text-red-500"
: "text-gray-500";
return (
<div
@@ -323,9 +333,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
/>
) : (
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? (
null
) : (
{typeof CategoryIcon === "string" ? null : (
<CategoryIcon className="w-10 h-10" />
)}
</div>
@@ -352,7 +360,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{(() => {
const catPt = (item as any).categoryPt as string | undefined;
const catEn = (item as any).category as string | undefined;
const catRaw = (catPt && String(catPt)) || (catEn && String(catEn)) || "";
const catRaw =
(catPt && String(catPt)) || (catEn && String(catEn)) || "";
const display = catRaw
? catRaw.charAt(0).toUpperCase() + catRaw.slice(1)
: "";
@@ -375,9 +384,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{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>
<span className="font-medium">{item.stats.attack} ATK</span>
</div>
)}
{item.stats.defense && (
@@ -391,21 +398,23 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{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>
<span className="font-medium">{item.stats.armor} ARM</span>
</div>
)}
{(item as any).volume && (
<div className="flex items-center gap-1.5 text-sm">
<Boxes className="h-4 w-4 text-primary" />
<span className="font-medium">Volume {(item as any).volume}</span>
<span className="font-medium">
Volume {(item as any).volume}
</span>
</div>
)}
{(item as any).bonus_de_skill && (
<div className="flex items-center gap-1.5 text-sm">
<Zap className="h-4 w-4 text-primary" />
<span className="font-medium">Skill +{(item as any).bonus_de_skill}</span>
<span className="font-medium">
Skill +{(item as any).bonus_de_skill}
</span>
</div>
)}
</div>
@@ -457,84 +466,91 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div>
)}
{(item as any).protecao && (() => {
const raw = String((item as any).protecao || "");
const tokens = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
if (tokens.length === 0) return null;
return (
<ScrollRow ariaLabel="elemental protections">
{tokens.map((raw: string, idx: number) => {
const token = raw.trim();
const match = token.match(/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/);
const type = (match?.[1] || token).trim().toLowerCase();
const value = (match?.[2] || "").trim();
let Icon: any = Shield;
let chipBg = "bg-amber-500/10";
let chipText = "text-amber-700";
switch (type) {
case "fire":
case "fogo":
Icon = Flame;
chipBg = "bg-red-500/10";
chipText = "text-red-600";
break;
case "ice":
case "gelo":
Icon = Snowflake;
chipBg = "bg-blue-500/10";
chipText = "text-blue-600";
break;
case "energy":
case "energia":
Icon = Energy;
chipBg = "bg-yellow-500/10";
chipText = "text-yellow-600";
break;
case "earth":
case "terra":
Icon = Leaf;
chipBg = "bg-green-500/10";
chipText = "text-green-600";
break;
case "death":
case "morte":
Icon = Skull;
chipBg = "bg-zinc-500/10";
chipText = "text-zinc-600";
break;
case "holy":
case "sagrado":
Icon = Cross;
chipBg = "bg-amber-500/10";
chipText = "text-amber-700";
break;
case "physical":
case "fisico":
Icon = Shield;
chipBg = "bg-slate-500/10";
chipText = "text-slate-600";
break;
default:
Icon = Shield;
chipBg = "bg-amber-500/10";
chipText = "text-amber-700";
}
const prettyType = type.charAt(0).toUpperCase() + type.slice(1);
const label = `${prettyType.replace("fisico", "Físico")}${value ? " " + value : ""}`;
return (
<div key={`${type}-${idx}`} className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}>
<Icon className="h-3 w-3" />
<span className="whitespace-nowrap">{label}</span>
</div>
);
})}
</ScrollRow>
);
})()}
{(item as any).protecao &&
(() => {
const raw = String((item as any).protecao || "");
const tokens = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
if (tokens.length === 0) return null;
return (
<ScrollRow ariaLabel="elemental protections">
{tokens.map((raw: string, idx: number) => {
const token = raw.trim();
const match = token.match(
/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/,
);
const type = (match?.[1] || token).trim().toLowerCase();
const value = (match?.[2] || "").trim();
let Icon: any = Shield;
let chipBg = "bg-amber-500/10";
let chipText = "text-amber-700";
switch (type) {
case "fire":
case "fogo":
Icon = Flame;
chipBg = "bg-red-500/10";
chipText = "text-red-600";
break;
case "ice":
case "gelo":
Icon = Snowflake;
chipBg = "bg-blue-500/10";
chipText = "text-blue-600";
break;
case "energy":
case "energia":
Icon = Energy;
chipBg = "bg-yellow-500/10";
chipText = "text-yellow-600";
break;
case "earth":
case "terra":
Icon = Leaf;
chipBg = "bg-green-500/10";
chipText = "text-green-600";
break;
case "death":
case "morte":
Icon = Skull;
chipBg = "bg-zinc-500/10";
chipText = "text-zinc-600";
break;
case "holy":
case "sagrado":
Icon = Cross;
chipBg = "bg-amber-500/10";
chipText = "text-amber-700";
break;
case "physical":
case "fisico":
Icon = Shield;
chipBg = "bg-slate-500/10";
chipText = "text-slate-600";
break;
default:
Icon = Shield;
chipBg = "bg-amber-500/10";
chipText = "text-amber-700";
}
const prettyType =
type.charAt(0).toUpperCase() + type.slice(1);
const label = `${prettyType.replace("fisico", "Físico")}${value ? ` ${value}` : ""}`;
return (
<div
key={`${type}-${idx}`}
className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}
>
<Icon className="h-3 w-3" />
<span className="whitespace-nowrap">{label}</span>
</div>
);
})}
</ScrollRow>
);
})()}
{item.stats.weight && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
@@ -552,7 +568,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div>
)}
{(item as any).dropa_de && (
{(item as any).dropa_de &&
(() => {
const raw = String((item as any).dropa_de || "");
const normalized = raw.trim();
@@ -588,8 +604,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</ScrollRow>
</div>
);
})()
)}
})()}
{mode === "market" && item.price && (
<>
@@ -649,7 +664,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<span className="text-sm font-medium text-muted-foreground">
{t("priceHistory")}
</span>
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
<div
className={`flex items-center gap-1 text-xs ${trendColor}`}
>
<TrendIcon className="h-3 w-3" />
<span className="capitalize">{trend}</span>
</div>
@@ -661,21 +678,27 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span>
</div>
<div className="font-bold">{formatNumber(item.priceHistory.highest, locale)} gp</div>
<div className="font-bold">
{formatNumber(item.priceHistory.highest, locale)} 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">{formatNumber(item.priceHistory.lowest, locale)} gp</div>
<div className="font-bold">
{formatNumber(item.priceHistory.lowest, locale)} 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">{formatNumber(item.priceHistory.average, locale)} gp</div>
<div className="font-bold">
{formatNumber(item.priceHistory.average, locale)} gp
</div>
</div>
</div>
</div>
@@ -690,7 +713,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
>
{t("viewDetails")}
</Button>
<Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm">
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
{t("buyNow")}
</Button>
</div>
+3 -9
View File
@@ -1,8 +1,9 @@
"use client";
import { Globe } from "lucide-react";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -10,8 +11,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
const languages = [
{ code: "pt", name: "Português", flag: "🇧🇷" },
@@ -37,7 +36,7 @@ export function LanguageSwitcher() {
router.push(`/${newLocale}${pathWithoutLocale}`);
};
const currentLanguage = languages.find((lang) => lang.code === locale);
const _currentLanguage = languages.find((lang) => lang.code === locale);
// Renderizar placeholder durante SSR para evitar mismatch
if (!mounted) {
@@ -72,8 +71,3 @@ export function LanguageSwitcher() {
</DropdownMenu>
);
}
+44 -54
View File
@@ -1,20 +1,10 @@
"use client";
import { useState } from "react";
import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Search,
Filter,
SortAsc,
SortDesc,
X,
Star,
TrendingUp,
Clock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,6 +13,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface MarketplaceFiltersProps {
onSearch: (query: string) => void;
@@ -48,7 +39,7 @@ export function MarketplaceFilters({
showRarity = true,
categoryOptionsOverride,
}: MarketplaceFiltersProps) {
const t = useTranslations("home");
const _t = useTranslations("home");
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<FilterState>({
rarity: [],
@@ -142,7 +133,9 @@ export function MarketplaceFilters({
};
const activeFiltersCount =
activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0);
activeFilters.rarity.length +
activeFilters.category.length +
(activeFilters.vocation ? 1 : 0);
return (
<div className="space-y-6">
@@ -159,39 +152,39 @@ export function MarketplaceFilters({
{/* Filtros e ordenação */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por raridade (opcional) */}
{showRarity && (
<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" />
{/* Filtro por raridade (opcional) */}
{showRarity && (
<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>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</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>
@@ -247,7 +240,9 @@ export function MarketplaceFilters({
const newFilters = {
...activeFilters,
vocation:
activeFilters.vocation === option.value ? null : option.value,
activeFilters.vocation === option.value
? null
: option.value,
};
setActiveFilters(newFilters);
onFilter(newFilters);
@@ -322,7 +317,7 @@ export function MarketplaceFilters({
})}
{activeFilters.category.map((category) => {
const option = categoryOptions.find(
(opt) => opt.value === category
(opt) => opt.value === category,
);
return (
<Badge
@@ -362,8 +357,3 @@ export function MarketplaceFilters({
</div>
);
}
File diff suppressed because it is too large. Load diff
+10 -4
View File
@@ -1,10 +1,17 @@
"use client";
import {
Clock,
Eye,
Heart,
MessageSquare,
MousePointerClick,
TrendingUp,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session";
import { TrendingUp, Eye, MousePointerClick, Heart, MessageSquare, Clock } from "lucide-react";
interface AdAnalytics {
ad_id: number;
@@ -37,13 +44,13 @@ export function AnalyticsDashboard() {
if (user?.userId) {
loadUserListings();
}
}, [user?.userId]);
}, [user?.userId, loadUserListings]);
useEffect(() => {
if (selectedAd) {
loadAnalytics(selectedAd);
}
}, [selectedAd]);
}, [selectedAd, loadAnalytics]);
const loadUserListings = async () => {
setLoading(true);
@@ -266,4 +273,3 @@ export function AnalyticsDashboard() {
</div>
);
}
+8 -11
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface AuctionCountdownProps {
@@ -30,7 +30,7 @@ export function AuctionCountdown({
const calculateTimeRemaining = () => {
const endTime = new Date(endsAt).getTime();
const now = new Date().getTime();
const now = Date.now();
const total = endTime - now;
if (total <= 0) {
@@ -61,19 +61,19 @@ export function AuctionCountdown({
// Determinar cor baseada no tempo restante
const getColorClass = () => {
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
if (timeRemaining.total <= 0) {
return "text-red-600 dark:text-red-400";
}
if (totalMinutes < 5) {
return "text-red-500 dark:text-red-400 font-semibold animate-pulse";
}
if (totalMinutes < 60) {
return "text-yellow-600 dark:text-yellow-400";
}
return "text-green-600 dark:text-green-400";
};
@@ -84,7 +84,7 @@ export function AuctionCountdown({
}
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
if (totalMinutes < 5) {
return "Termina em breve";
}
@@ -102,13 +102,10 @@ export function AuctionCountdown({
return (
<div className={cn("flex items-center gap-1.5", className)}>
{showIcon && (
<Clock className={cn("h-3.5 w-3.5", getColorClass())} />
)}
{showIcon && <Clock className={cn("h-3.5 w-3.5", getColorClass())} />}
<span className={cn("text-xs font-medium", getColorClass())}>
{getTimeText()}
</span>
</div>
);
}
+78 -36
View File
@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { Separator } from "@/components/ui/separator";
import { Loader2, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -10,7 +11,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -20,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Coins, DollarSign, Loader2, TrendingUp } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { useSession } from "@/lib/hooks/use-session";
interface Listing {
@@ -45,20 +45,27 @@ interface BidModalProps {
onSuccess?: () => void;
}
export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalProps) {
export function BidModal({
open,
onOpenChange,
listing,
onSuccess,
}: BidModalProps) {
const { user } = useSession();
const [bidAmount, setBidAmount] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bidHistory, setBidHistory] = useState<Array<{
bid_id: number;
bidder_id: number;
bidder_name: string;
bid_amount: number;
bid_type: string | null;
created_at: Date;
}>>([]);
const [bidHistory, setBidHistory] = useState<
Array<{
bid_id: number;
bidder_id: number;
bidder_name: string;
bid_amount: number;
bid_type: string | null;
created_at: Date;
}>
>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
if (!listing) return null;
@@ -67,41 +74,50 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
const currentBid = listing.current_bid_amount || 0;
const minIncrement = listing.min_bid_increment || 10;
const minBid = currentBid + minIncrement;
const initialPrice = listing.initial_price_pix || listing.initial_price_tibia_coins || listing.initial_price_gold_coins || 0;
const initialPrice =
listing.initial_price_pix ||
listing.initial_price_tibia_coins ||
listing.initial_price_gold_coins ||
0;
// Determinar métodos de pagamento disponíveis para lance
const availableMethods: string[] = [];
if (listing.price_pix || listing.initial_price_pix) availableMethods.push("pix");
if (listing.price_tibia_coins || listing.initial_price_tibia_coins) availableMethods.push("tc");
if (listing.price_gold_coins || listing.initial_price_gold_coins) availableMethods.push("gp");
if (listing.price_pix || listing.initial_price_pix)
availableMethods.push("pix");
if (listing.price_tibia_coins || listing.initial_price_tibia_coins)
availableMethods.push("tc");
if (listing.price_gold_coins || listing.initial_price_gold_coins)
availableMethods.push("gp");
// Selecionar primeiro método disponível por padrão
useEffect(() => {
if (availableMethods.length > 0 && !paymentMethod) {
setPaymentMethod(availableMethods[0]);
}
}, [availableMethods.length]);
}, [paymentMethod]);
// Atualizar valor do lance quando mudar
useEffect(() => {
if (bidAmount === "" && minBid > 0) {
setBidAmount(minBid.toFixed(2));
}
}, [minBid]);
}, [minBid, bidAmount]);
// Buscar histórico de lances quando abrir modal
useEffect(() => {
if (open && listing) {
fetchBidHistory();
}
}, [open, listing?.listing_id]);
}, [open, listing?.listing_id, fetchBidHistory, listing]);
const fetchBidHistory = async () => {
if (!listing) return;
setLoadingHistory(true);
try {
const res = await fetch(`/api/listings/${listing.listing_id}/bids?limit=5`);
const res = await fetch(
`/api/listings/${listing.listing_id}/bids?limit=5`,
);
if (res.ok) {
const data = await res.json();
if (data.success) {
@@ -117,14 +133,16 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
const handleBid = async () => {
const amount = parseFloat(bidAmount);
if (isNaN(amount) || amount <= 0) {
if (Number.isNaN(amount) || amount <= 0) {
setError("Valor do lance inválido");
return;
}
if (amount < minBid) {
setError(`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`);
setError(
`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`,
);
return;
}
@@ -198,11 +216,17 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Preço Inicial</Label>
<p className="text-lg font-semibold">{formatPrice(initialPrice)}</p>
<Label className="text-xs text-muted-foreground">
Preço Inicial
</Label>
<p className="text-lg font-semibold">
{formatPrice(initialPrice)}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Lance Atual</Label>
<Label className="text-xs text-muted-foreground">
Lance Atual
</Label>
<p className="text-lg font-semibold text-primary">
{currentBid > 0 ? formatPrice(currentBid) : "Sem lances"}
</p>
@@ -211,18 +235,28 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Incremento Mínimo</Label>
<p className="text-sm font-medium">{formatPrice(minIncrement)}</p>
<Label className="text-xs text-muted-foreground">
Incremento Mínimo
</Label>
<p className="text-sm font-medium">
{formatPrice(minIncrement)}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Lance Mínimo Necessário</Label>
<p className="text-sm font-semibold text-green-600">{formatPrice(minBid)}</p>
<Label className="text-xs text-muted-foreground">
Lance Mínimo Necessário
</Label>
<p className="text-sm font-semibold text-green-600">
{formatPrice(minBid)}
</p>
</div>
</div>
{listing.auction_ends_at && (
<div>
<Label className="text-xs text-muted-foreground">Término do Leilão</Label>
<Label className="text-xs text-muted-foreground">
Término do Leilão
</Label>
<p className="text-sm">
{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}
</p>
@@ -235,7 +269,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
{/* Histórico de lances */}
{bidHistory.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Histórico de Lances (Últimos 5)</Label>
<Label className="text-sm font-medium">
Histórico de Lances (Últimos 5)
</Label>
<div className="bg-muted rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{bidHistory.map((bid, index) => {
const isCurrentUser = user?.userId === bid.bidder_id;
@@ -244,7 +280,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div
key={bid.bid_id}
className={`p-2 rounded ${
isCurrentUser ? "bg-primary/10 border border-primary/20" : ""
isCurrentUser
? "bg-primary/10 border border-primary/20"
: ""
} ${isHighest ? "border-l-2 border-l-primary" : ""}`}
>
<div className="flex items-center justify-between">
@@ -343,7 +381,12 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
</Button>
<Button
onClick={handleBid}
disabled={loading || !bidAmount || parseFloat(bidAmount) < minBid || !paymentMethod}
disabled={
loading ||
!bidAmount ||
parseFloat(bidAmount) < minBid ||
!paymentMethod
}
>
{loading ? (
<>
@@ -363,4 +406,3 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
</Dialog>
);
}
+29 -17
View File
@@ -1,6 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { Package } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -8,12 +11,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
@@ -21,9 +20,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/hooks/use-session";
import { ItemSearch } from "./ItemSearch";
import { ArrowRight, Package } from "lucide-react";
interface Item {
id: number;
@@ -47,7 +47,11 @@ interface Server {
isActive: boolean;
}
export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalProps) {
export function CreateAdModal({
open,
onOpenChange,
onSuccess,
}: CreateAdModalProps) {
const { user } = useSession();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -97,7 +101,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
if (!hasPrice && (!acceptTrade || !tradeItem)) {
setError(
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item"
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item",
);
setLoading(false);
return;
@@ -130,9 +134,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
description: formData.description || null,
trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null,
trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null,
trade_item_quantity: acceptTrade && tradeItem
? parseInt(formData.trade_item_quantity, 10)
: null,
trade_item_quantity:
acceptTrade && tradeItem
? parseInt(formData.trade_item_quantity, 10)
: null,
}),
});
@@ -155,10 +160,14 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
auction_ends_at: "Término do leilão",
};
const validationMessages = data.details.map((detail: { field?: string; message?: string }) => {
const fieldLabel = detail.field ? fieldLabels[detail.field] || detail.field : "Campo";
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
});
const validationMessages = data.details.map(
(detail: { field?: string; message?: string }) => {
const fieldLabel = detail.field
? fieldLabels[detail.field] || detail.field
: "Campo";
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
},
);
setError(validationMessages.join(" "));
} else {
@@ -199,7 +208,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
if (open) {
loadServers();
}
}, [open]);
}, [open, loadServers]);
const loadServers = async () => {
setLoadingServers(true);
@@ -295,7 +304,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
</SelectItem>
) : (
servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverName}>
<SelectItem
key={server.serverId}
value={server.serverName}
>
{server.serverName}
{server.location && ` (${server.location})`}
</SelectItem>
+80 -39
View File
@@ -1,6 +1,20 @@
"use client";
import { useState, useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
Clock,
Loader2,
MessageSquare,
ShoppingBag,
ShoppingCart,
User,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,24 +22,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
ShoppingCart,
Clock,
CheckCircle2,
XCircle,
MessageSquare,
User,
Loader2,
ShoppingBag,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface PurchaseIntention {
intention_id: number;
@@ -72,21 +72,30 @@ function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Badge
variant="secondary"
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
>
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<Badge
variant="secondary"
className="bg-green-500/20 text-green-700 dark:text-green-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<Badge
variant="secondary"
className="bg-red-500/20 text-red-700 dark:text-red-400"
>
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
@@ -96,7 +105,9 @@ function getStatusBadge(status: string) {
}
}
function formatCustomOffer(intention: PurchaseIntention): { value: string; date: string | null } | null {
function formatCustomOffer(
intention: PurchaseIntention,
): { value: string; date: string | null } | null {
if (
intention.custom_offer_amount === null ||
intention.custom_offer_amount === undefined ||
@@ -142,7 +153,10 @@ export function IntentionDetailModal({
const [sellerResponse, setSellerResponse] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [listing, setListing] = useState<{ seller_id: number; listing_status: string } | null>(null);
const [listing, setListing] = useState<{
seller_id: number;
listing_status: string;
} | null>(null);
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
@@ -161,7 +175,11 @@ export function IntentionDetailModal({
}, [open, listingId]);
const isOwner = user && listing && user.userId === listing.seller_id;
const canMarkAsSold = isOwner && listing && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
const canMarkAsSold =
isOwner &&
listing &&
(listing.listing_status === "active" ||
listing.listing_status === "in_escrow");
const canAcceptOrReject = intention.status === "pending";
const customOffer = formatCustomOffer(intention);
@@ -169,7 +187,7 @@ export function IntentionDetailModal({
if (open) {
setSellerResponse(intention.seller_response ?? "");
}
}, [open, intention.intention_id, intention.seller_response]);
}, [open, intention.seller_response]);
const handleAccept = async () => {
setLoading(true);
@@ -185,7 +203,7 @@ export function IntentionDetailModal({
status: "accepted",
seller_response: sellerResponse || undefined,
}),
}
},
);
const data = await res.json();
@@ -217,7 +235,7 @@ export function IntentionDetailModal({
status: "rejected",
seller_response: sellerResponse || undefined,
}),
}
},
);
const data = await res.json();
@@ -229,7 +247,9 @@ export function IntentionDetailModal({
if (onUpdate) onUpdate();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao rejeitar intenção");
setError(
err instanceof Error ? err.message : "Erro ao rejeitar intenção",
);
} finally {
setLoading(false);
}
@@ -241,7 +261,7 @@ export function IntentionDetailModal({
const res = await fetch(`/api/listings/${listingId}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
@@ -250,13 +270,15 @@ export function IntentionDetailModal({
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
if (onUpdate) onUpdate();
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
alert(
error instanceof Error ? error.message : "Erro ao marcar como vendido",
);
} finally {
setMarkingSold(false);
}
@@ -271,7 +293,8 @@ export function IntentionDetailModal({
{getStatusBadge(intention.status)}
</DialogTitle>
<DialogDescription>
Detalhes da intenção de compra de {intention.buyer?.username || "usuário"}
Detalhes da intenção de compra de{" "}
{intention.buyer?.username || "usuário"}
</DialogDescription>
</DialogHeader>
@@ -304,7 +327,9 @@ export function IntentionDetailModal({
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs text-muted-foreground">Método de Pagamento</Label>
<Label className="text-xs text-muted-foreground">
Método de Pagamento
</Label>
<div className="flex items-center gap-2 mt-1">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
@@ -313,7 +338,9 @@ export function IntentionDetailModal({
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Data de Criação</Label>
<Label className="text-xs text-muted-foreground">
Data de Criação
</Label>
<div className="flex items-center gap-2 mt-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
@@ -328,9 +355,13 @@ export function IntentionDetailModal({
<Label className="text-xs text-primary uppercase tracking-wide">
Oferta do Comprador
</Label>
<div className="text-base font-semibold text-primary">{customOffer.value}</div>
<div className="text-base font-semibold text-primary">
{customOffer.value}
</div>
{customOffer.date && (
<p className="text-[11px] text-primary/70">Enviada em {customOffer.date}</p>
<p className="text-[11px] text-primary/70">
Enviada em {customOffer.date}
</p>
)}
</div>
)}
@@ -398,7 +429,11 @@ export function IntentionDetailModal({
Marcar como Vendido
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading || markingSold}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading || markingSold}
>
Fechar
</Button>
{canAcceptOrReject && (
@@ -420,7 +455,10 @@ export function IntentionDetailModal({
</>
)}
</Button>
<Button onClick={handleAccept} disabled={loading || markingSold}>
<Button
onClick={handleAccept}
disabled={loading || markingSold}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -445,14 +483,18 @@ export function IntentionDetailModal({
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
Ao marcar este anúncio como vendido, as seguintes ações serão
executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
<li>
Após 1 hora, o anúncio sairá automaticamente da lista de
anúncios ativos
</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
@@ -480,4 +522,3 @@ export function IntentionDetailModal({
</Dialog>
);
}
+37 -28
View File
@@ -1,12 +1,12 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Loader2, Search, X } from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Search, X, Loader2 } from "lucide-react";
import Image from "next/image";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface Item {
@@ -38,7 +38,9 @@ export function ItemSearch({
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [previewItem, setPreviewItem] = useState<Item | null>(selectedItem || null);
const [previewItem, setPreviewItem] = useState<Item | null>(
selectedItem || null,
);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -58,7 +60,7 @@ export function ItemSearch({
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`,
);
if (res.ok) {
const data = await res.json();
@@ -76,7 +78,10 @@ export function ItemSearch({
// Fechar dropdown ao clicar fora
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
if (
searchRef.current &&
!searchRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
};
@@ -159,7 +164,9 @@ export function ItemSearch({
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p>
{item.category && (
<p className="text-xs text-muted-foreground">{item.category}</p>
<p className="text-xs text-muted-foreground">
{item.category}
</p>
)}
</div>
{item.rarity && (
@@ -200,24 +207,27 @@ export function ItemSearch({
{previewItem.rarity && (
<Badge variant="secondary">{previewItem.rarity}</Badge>
)}
{previewItem.level !== undefined && previewItem.level !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Level:</span>
{previewItem.level}
</span>
)}
{previewItem.weight !== undefined && previewItem.weight !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Peso:</span>
{previewItem.weight} oz
</span>
)}
{previewItem.npc_price !== undefined && previewItem.npc_price !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Preço NPC:</span>
{previewItem.npc_price.toLocaleString()} gp
</span>
)}
{previewItem.level !== undefined &&
previewItem.level !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Level:</span>
{previewItem.level}
</span>
)}
{previewItem.weight !== undefined &&
previewItem.weight !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Peso:</span>
{previewItem.weight} oz
</span>
)}
{previewItem.npc_price !== undefined &&
previewItem.npc_price !== null && (
<span className="flex items-center gap-1">
<span className="font-medium">Preço NPC:</span>
{previewItem.npc_price.toLocaleString()} gp
</span>
)}
</div>
</div>
</div>
@@ -227,4 +237,3 @@ export function ItemSearch({
</div>
);
}
+181 -71
View File
@@ -1,34 +1,32 @@
"use client";
import { useState, useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Eye,
Heart,
MapPin,
Pause,
ShoppingBag,
Star,
Trash2,
TrendingUp,
X,
} from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Eye,
Heart,
Clock,
MapPin,
DollarSign,
Coins,
TrendingUp,
Star,
Pause,
Trash2,
X,
CheckCircle2,
AlertCircle,
ShoppingBag,
} from "lucide-react";
import Image from "next/image";
import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface Listing {
listing_id: number;
@@ -76,10 +74,11 @@ interface ListingDetailsModalProps {
function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
const seconds = Math.floor((new Date().getTime() - dateObj.getTime()) / 1000);
const seconds = Math.floor((Date.now() - dateObj.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
if (minutes < 60)
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
@@ -92,14 +91,40 @@ function formatTimeAgo(date: Date | string): string {
function getStatusBadge(status: string) {
const statusConfig = {
active: { label: "Ativo", variant: "default" as const, icon: CheckCircle2, className: "bg-green-500 hover:bg-green-600" },
sold: { label: "Vendido", variant: "secondary" as const, icon: CheckCircle2, className: "bg-blue-500 hover:bg-blue-600 text-white" },
cancelled: { label: "Cancelado", variant: "destructive" as const, icon: X, className: "bg-red-500 hover:bg-red-600 text-white" },
in_escrow: { label: "Em Escrow", variant: "default" as const, icon: AlertCircle, className: "bg-yellow-500 hover:bg-yellow-600 text-white" },
expired: { label: "Expirado", variant: "secondary" as const, icon: Clock, className: "bg-gray-500 hover:bg-gray-600 text-white" },
active: {
label: "Ativo",
variant: "default" as const,
icon: CheckCircle2,
className: "bg-green-500 hover:bg-green-600",
},
sold: {
label: "Vendido",
variant: "secondary" as const,
icon: CheckCircle2,
className: "bg-blue-500 hover:bg-blue-600 text-white",
},
cancelled: {
label: "Cancelado",
variant: "destructive" as const,
icon: X,
className: "bg-red-500 hover:bg-red-600 text-white",
},
in_escrow: {
label: "Em Escrow",
variant: "default" as const,
icon: AlertCircle,
className: "bg-yellow-500 hover:bg-yellow-600 text-white",
},
expired: {
label: "Expirado",
variant: "secondary" as const,
icon: Clock,
className: "bg-gray-500 hover:bg-gray-600 text-white",
},
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
const config =
statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
const Icon = config.icon;
return (
@@ -123,7 +148,7 @@ export function ListingDetailsModal({
const [itemImageUrl, setItemImageUrl] = useState<string>("");
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false);
// Verificar se o usuário é o dono do anúncio
const isOwner = user && user.userId === listing.seller_id;
@@ -134,13 +159,19 @@ export function ListingDetailsModal({
} else if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) {
setItemImageUrl(`/api/images?path=${encodeURIComponent(normalizedPath)}`);
setItemImageUrl(
`/api/images?path=${encodeURIComponent(normalizedPath)}`,
);
} else {
const category = listing.item_category || "other";
setItemImageUrl(`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`);
setItemImageUrl(
`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`,
);
}
} else if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
const params = new URLSearchParams({
item: listing.item_id_unified.toString(),
});
if (listing.item_name) {
params.set("name", listing.item_name);
}
@@ -158,27 +189,52 @@ export function ListingDetailsModal({
const prices: Array<{ type: string; label: string; value: string }> = [];
if (listing.price_pix) {
const price = typeof listing.price_pix === "string" ? parseFloat(listing.price_pix) : listing.price_pix;
if (!isNaN(price)) {
prices.push({ type: "pix", label: "PIX", value: `R$ ${price.toFixed(2)}` });
const price =
typeof listing.price_pix === "string"
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!Number.isNaN(price)) {
prices.push({
type: "pix",
label: "PIX",
value: `R$ ${price.toFixed(2)}`,
});
}
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === "string" ? parseInt(listing.price_tibia_coins, 10) : listing.price_tibia_coins;
if (!isNaN(price)) {
prices.push({ type: "tibia_coins", label: "Tibia Coins", value: `${price.toLocaleString()} TC` });
const price =
typeof listing.price_tibia_coins === "string"
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!Number.isNaN(price)) {
prices.push({
type: "tibia_coins",
label: "Tibia Coins",
value: `${price.toLocaleString()} TC`,
});
}
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === "string" ? parseInt(listing.price_gold_coins, 10) : listing.price_gold_coins;
if (!isNaN(price)) {
prices.push({ type: "gold_coins", label: "Gold Coins", value: `${price.toLocaleString()} GP` });
const price =
typeof listing.price_gold_coins === "string"
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!Number.isNaN(price)) {
prices.push({
type: "gold_coins",
label: "Gold Coins",
value: `${price.toLocaleString()} GP`,
});
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
if (
listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity
) {
prices.push({
type: "trade",
label: "Troca",
@@ -220,7 +276,10 @@ export function ListingDetailsModal({
const canPause = isOwner && listing.listing_status === "active";
const canActivate = isOwner && listing.listing_status === "cancelled";
const canCancel = isOwner && listing.listing_status === "active";
const canMarkAsSold = isOwner && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
const canMarkAsSold =
isOwner &&
(listing.listing_status === "active" ||
listing.listing_status === "in_escrow");
const handleMarkAsSold = async () => {
setMarkingSold(true);
@@ -228,7 +287,7 @@ export function ListingDetailsModal({
const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido");
@@ -237,12 +296,14 @@ export function ListingDetailsModal({
// Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
setShowMarkSoldDialog(false);
onOpenChange(false);
} catch (error) {
console.error("Erro ao marcar como vendido:", error);
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
alert(
error instanceof Error ? error.message : "Erro ao marcar como vendido",
);
} finally {
setMarkingSold(false);
}
@@ -256,7 +317,9 @@ export function ListingDetailsModal({
Detalhes do Anúncio
{getStatusBadge(listing.listing_status)}
</DialogTitle>
<DialogDescription>Informações completas do seu anúncio</DialogDescription>
<DialogDescription>
Informações completas do seu anúncio
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
@@ -294,22 +357,30 @@ export function ListingDetailsModal({
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Visualizações</div>
<div className="text-sm text-muted-foreground">
Visualizações
</div>
<div className="font-semibold">{listing.views_count}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Favoritos</div>
<div className="font-semibold">{listing.favorites_count || 0}</div>
<div className="text-sm text-muted-foreground">
Favoritos
</div>
<div className="font-semibold">
{listing.favorites_count || 0}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm text-muted-foreground">Criado</div>
<div className="font-semibold text-xs">{formatTimeAgo(listing.created_at)}</div>
<div className="font-semibold text-xs">
{formatTimeAgo(listing.created_at)}
</div>
</div>
</div>
</div>
@@ -349,7 +420,9 @@ export function ListingDetailsModal({
: "bg-purple-500/10 border-purple-500/20"
}`}
>
<div className="text-xs text-muted-foreground mb-1">{price.label}</div>
<div className="text-xs text-muted-foreground mb-1">
{price.label}
</div>
<div className="font-semibold">{price.value}</div>
</div>
))}
@@ -370,26 +443,44 @@ export function ListingDetailsModal({
<div className="flex justify-between">
<span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-lg text-primary">
R$ {typeof listing.current_bid_amount === "number" ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
R${" "}
{typeof listing.current_bid_amount === "number"
? listing.current_bid_amount.toFixed(2)
: parseFloat(
String(listing.current_bid_amount),
).toFixed(2)}
</span>
</div>
{listing.min_bid_increment && (
<div className="flex justify-between">
<span className="text-muted-foreground">Incremento Mínimo:</span>
<span className="text-muted-foreground">
Incremento Mínimo:
</span>
<span className="font-medium">
R$ {typeof listing.min_bid_increment === "number" ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)}
R${" "}
{typeof listing.min_bid_increment === "number"
? listing.min_bid_increment.toFixed(2)
: parseFloat(
String(listing.min_bid_increment),
).toFixed(2)}
</span>
</div>
)}
{listing.auction_ends_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Termina em:</span>
<span className="font-medium">{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}</span>
<span className="font-medium">
{new Date(listing.auction_ends_at).toLocaleString(
"pt-BR",
)}
</span>
</div>
)}
</div>
) : (
<div className="text-center text-muted-foreground py-2">Sem lances ainda</div>
<div className="text-center text-muted-foreground py-2">
Sem lances ainda
</div>
)}
</div>
)}
@@ -398,7 +489,9 @@ export function ListingDetailsModal({
{listing.description && (
<div className="space-y-2">
<h4 className="font-semibold">Descrição</h4>
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">{listing.description}</p>
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">
{listing.description}
</p>
</div>
)}
@@ -410,13 +503,19 @@ export function ListingDetailsModal({
</div>
{listing.item_id_unified && (
<div>
<span className="text-muted-foreground">ID do Item (Unificado):</span>
<span className="ml-2 font-mono">{listing.item_id_unified}</span>
<span className="text-muted-foreground">
ID do Item (Unificado):
</span>
<span className="ml-2 font-mono">
{listing.item_id_unified}
</span>
</div>
)}
<div>
<span className="text-muted-foreground">Criado em:</span>
<span className="ml-2">{new Date(listing.created_at).toLocaleString("pt-BR")}</span>
<span className="ml-2">
{new Date(listing.created_at).toLocaleString("pt-BR")}
</span>
</div>
</div>
@@ -424,9 +523,9 @@ export function ListingDetailsModal({
{(canPause || canActivate || canCancel || canMarkAsSold) && (
<div className="flex gap-2 pt-4 border-t">
{canMarkAsSold && (
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
<Button
variant="default"
onClick={() => setShowMarkSoldDialog(true)}
className="flex-1 bg-green-600 hover:bg-green-700"
>
<ShoppingBag className="h-4 w-4 mr-2" />
@@ -440,13 +539,21 @@ export function ListingDetailsModal({
</Button>
)}
{canActivate && onActivate && (
<Button variant="default" onClick={onActivate} className="flex-1">
<Button
variant="default"
onClick={onActivate}
className="flex-1"
>
<CheckCircle2 className="h-4 w-4 mr-2" />
Reativar Anúncio
</Button>
)}
{canCancel && (
<Button variant="destructive" onClick={onCancel} className="flex-1">
<Button
variant="destructive"
onClick={onCancel}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
Cancelar Anúncio
</Button>
@@ -462,14 +569,18 @@ export function ListingDetailsModal({
<DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription>
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
Ao marcar este anúncio como vendido, as seguintes ações serão
executadas:
</DialogDescription>
</DialogHeader>
<div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>O anúncio será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</li>
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
<li>
Após 1 hora, o anúncio sairá automaticamente da lista de
anúncios ativos
</li>
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul>
<p className="pt-2 font-medium text-foreground">
@@ -497,4 +608,3 @@ export function ListingDetailsModal({
</Dialog>
);
}
+47 -17
View File
@@ -1,11 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import {
CheckCircle2,
Clock,
MessageSquare,
ShoppingCart,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ShoppingCart, Clock, CheckCircle2, XCircle, MessageSquare } from "lucide-react";
import { IntentionDetailModal } from "./IntentionDetailModal";
interface PurchaseIntention {
@@ -32,10 +38,11 @@ interface MyAdsIntentionListProps {
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
if (minutes < 60)
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
@@ -46,21 +53,30 @@ function getStatusBadge(status: string) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Badge
variant="secondary"
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
>
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<Badge
variant="secondary"
className="bg-green-500/20 text-green-700 dark:text-green-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<Badge
variant="secondary"
className="bg-red-500/20 text-red-700 dark:text-red-400"
>
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
@@ -107,15 +123,21 @@ function formatCustomOffer(intention: PurchaseIntention): string | null {
}
}
export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListProps) {
export function MyAdsIntentionList({
listingId,
sellerId,
}: MyAdsIntentionListProps) {
const [intentions, setIntentions] = useState<PurchaseIntention[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIntention, setSelectedIntention] = useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<"all" | "pending" | "accepted" | "rejected">("all");
const [selectedIntention, setSelectedIntention] =
useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<
"all" | "pending" | "accepted" | "rejected"
>("all");
useEffect(() => {
loadIntentions();
}, [listingId, filter]);
}, [loadIntentions]);
const loadIntentions = async () => {
setLoading(true);
@@ -124,7 +146,9 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
if (filter !== "all") {
params.set("status", filter);
}
const res = await fetch(`/api/listings/${listingId}/intentions?${params.toString()}`);
const res = await fetch(
`/api/listings/${listingId}/intentions?${params.toString()}`,
);
if (res.ok) {
const data = await res.json();
if (data.success) {
@@ -216,19 +240,26 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<ShoppingCart className="h-3 w-3" />
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
<span>
{getPaymentMethodLabel(intention.payment_method)}
</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
</div>
{formatCustomOffer(intention) && (
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="border-primary text-primary">
<Badge
variant="outline"
className="border-primary text-primary"
>
Oferta: {formatCustomOffer(intention)}
</Badge>
{intention.custom_offer_created_at && (
<span className="text-[10px] text-muted-foreground">
{new Date(intention.custom_offer_created_at).toLocaleString("pt-BR")}
{new Date(
intention.custom_offer_created_at,
).toLocaleString("pt-BR")}
</span>
)}
</div>
@@ -261,4 +292,3 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
</div>
);
}
+73 -67
View File
@@ -1,39 +1,43 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Pause,
Play,
Trash2,
Edit,
ChevronDown,
Eye,
Heart,
TrendingUp,
ChevronDown,
Pause,
Play,
ShoppingCart,
Trash2,
TrendingUp,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useMyListings } from "@/lib/hooks/use-listings";
import { useSession } from "@/lib/hooks/use-session";
import { useMyListings, type Listing } from "@/lib/hooks/use-listings";
import { AdCard } from "./AdCard";
import { MyAdsIntentionList } from "./MyAdsIntentionList";
export function MyAdsPanel() {
const { user } = useSession();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<string>("active");
const [expandedIntentions, setExpandedIntentions] = useState<Set<number>>(new Set());
const [expandedIntentions, setExpandedIntentions] = useState<Set<number>>(
new Set(),
);
// Determinar status baseado na aba ativa
const status =
activeTab === "active" ? "active" :
activeTab === "paused" ? "cancelled" : // Backend usa 'cancelled' para pausados
activeTab === "sold" ? "sold" :
undefined; // "all" busca todos
const status =
activeTab === "active"
? "active"
: activeTab === "paused"
? "cancelled"
: // Backend usa 'cancelled' para pausados
activeTab === "sold"
? "sold"
: undefined; // "all" busca todos
// Usar React Query para buscar meus anúncios com atualização automática
// O queryKey muda quando status muda, então recarrega automaticamente
@@ -145,9 +149,7 @@ export function MyAdsPanel() {
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum anúncio encontrado
</p>
<p className="text-muted-foreground">Nenhum anúncio encontrado</p>
</div>
) : (
<div className="space-y-4">
@@ -180,21 +182,25 @@ export function MyAdsPanel() {
<span>
{(() => {
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
const price =
typeof listing.price_pix === "string"
? parseFloat(listing.price_pix)
: listing.price_pix;
return `R$ ${price.toFixed(2)}`;
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string'
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
const price =
typeof listing.price_tibia_coins ===
"string"
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
return `${price.toLocaleString()} TC`;
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string'
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
const price =
typeof listing.price_gold_coins === "string"
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
return `${price.toLocaleString()} GP`;
}
return "Preço não informado";
@@ -236,42 +242,43 @@ export function MyAdsPanel() {
</div>
{/* Seção de Intenções de Compra */}
{listing.listing_status === "active" && !listing.is_auction && (
<div className="border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newExpanded = new Set(expandedIntentions);
if (isExpanded) {
newExpanded.delete(listing.listing_id);
} else {
newExpanded.add(listing.listing_id);
}
setExpandedIntentions(newExpanded);
}}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
<span>Intenções de Compra</span>
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isExpanded ? "transform rotate-180" : ""
}`}
/>
</Button>
{isExpanded && (
<div className="mt-4">
<MyAdsIntentionList
listingId={listing.listing_id}
sellerId={listing.seller_id}
{listing.listing_status === "active" &&
!listing.is_auction && (
<div className="border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newExpanded = new Set(expandedIntentions);
if (isExpanded) {
newExpanded.delete(listing.listing_id);
} else {
newExpanded.add(listing.listing_id);
}
setExpandedIntentions(newExpanded);
}}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
<span>Intenções de Compra</span>
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isExpanded ? "transform rotate-180" : ""
}`}
/>
</div>
)}
</div>
)}
</Button>
{isExpanded && (
<div className="mt-4">
<MyAdsIntentionList
listingId={listing.listing_id}
sellerId={listing.seller_id}
/>
</div>
)}
</div>
)}
</div>
);
})}
@@ -282,4 +289,3 @@ export function MyAdsPanel() {
</div>
);
}
@@ -1,31 +1,28 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Clock,
CheckCircle2,
XCircle,
MessageSquare,
ShoppingCart,
MapPin,
Package,
User,
ExternalLink,
AlertCircle,
DollarSign,
CheckCircle2,
Clock,
Coins,
DollarSign,
ExternalLink,
MapPin,
MessageSquare,
Package,
RefreshCw,
ShoppingCart,
User,
XCircle,
} from "lucide-react";
import { useMyPurchaseIntentions, type PurchaseIntention, type PurchaseIntentionStatus } from "@/lib/hooks/use-purchase-intentions";
import { useSession } from "@/lib/hooks/use-session";
import Link from "next/link";
import Image from "next/image";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useState } from "react";
import { ChatModal } from "@/components/chat/ChatModal";
import { ChangePaymentMethodButton } from "@/components/purchase-intentions/ChangePaymentMethodButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -33,14 +30,22 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ChatModal } from "@/components/chat/ChatModal";
import { Skeleton } from "@/components/ui/skeleton";
import {
type PurchaseIntention,
type PurchaseIntentionStatus,
useMyPurchaseIntentions,
} from "@/lib/hooks/use-purchase-intentions";
import { useSession } from "@/lib/hooks/use-session";
import { cn } from "@/lib/utils";
function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
const seconds = Math.floor((new Date().getTime() - dateObj.getTime()) / 1000);
const seconds = Math.floor((Date.now() - dateObj.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
if (minutes < 60)
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
@@ -55,35 +60,50 @@ function getStatusBadge(status: PurchaseIntentionStatus) {
switch (status) {
case "pending":
return (
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
<Badge
variant="secondary"
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
>
<Clock className="h-3 w-3 mr-1" />
Pendente
</Badge>
);
case "accepted":
return (
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
<Badge
variant="secondary"
className="bg-green-500/20 text-green-700 dark:text-green-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Aceita
</Badge>
);
case "rejected":
return (
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
<Badge
variant="secondary"
className="bg-red-500/20 text-red-700 dark:text-red-400"
>
<XCircle className="h-3 w-3 mr-1" />
Rejeitada
</Badge>
);
case "expired":
return (
<Badge variant="secondary" className="bg-gray-500/20 text-gray-700 dark:text-gray-400">
<Badge
variant="secondary"
className="bg-gray-500/20 text-gray-700 dark:text-gray-400"
>
<AlertCircle className="h-3 w-3 mr-1" />
Expirada
</Badge>
);
case "completed":
return (
<Badge variant="secondary" className="bg-blue-500/20 text-blue-700 dark:text-blue-400">
<Badge
variant="secondary"
className="bg-blue-500/20 text-blue-700 dark:text-blue-400"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Concluída
</Badge>
@@ -110,18 +130,18 @@ function getPaymentMethodLabel(method: string): string {
function formatPrice(price: number | null, method: string): string {
if (price === null) return "N/A";
if (method === "pix") {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(price);
}
if (method === "tibia_coins") {
return `${price.toLocaleString("pt-BR")} TC`;
}
if (method === "gold_coins") {
const gold = price / 1000000;
if (gold >= 1) {
@@ -129,11 +149,14 @@ function formatPrice(price: number | null, method: string): string {
}
return `${price.toLocaleString("pt-BR")} gp`;
}
return price.toLocaleString("pt-BR");
}
function formatCustomOffer(amount: number | null | undefined, currency: string | null | undefined): string | null {
function formatCustomOffer(
amount: number | null | undefined,
currency: string | null | undefined,
): string | null {
if (amount === null || amount === undefined || !currency) {
return null;
}
@@ -171,7 +194,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg">Anúncio não disponível</h3>
<h3 className="font-semibold text-lg">
Anúncio não disponível
</h3>
{getStatusBadge(intention.status)}
</div>
<p className="text-sm text-muted-foreground">
@@ -203,7 +228,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`;
}
if (listing.item_id_unified) {
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
const params = new URLSearchParams({
item: listing.item_id_unified.toString(),
});
if (listing.item_name) {
params.set("name", listing.item_name);
}
@@ -223,24 +250,27 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
? `${listing.listing_id}_${intention.buyer_id}_${listing.seller_id}`
: null);
const price = listing.price_pix && intention.payment_method === "pix"
? listing.price_pix
: listing.price_tibia_coins && intention.payment_method === "tibia_coins"
? listing.price_tibia_coins
: listing.price_gold_coins && intention.payment_method === "gold_coins"
? listing.price_gold_coins
: null;
const price =
listing.price_pix && intention.payment_method === "pix"
? listing.price_pix
: listing.price_tibia_coins && intention.payment_method === "tibia_coins"
? listing.price_tibia_coins
: listing.price_gold_coins && intention.payment_method === "gold_coins"
? listing.price_gold_coins
: null;
const customOfferLabel = formatCustomOffer(
typeof intention.custom_offer_amount === "number" ? intention.custom_offer_amount : null,
intention.custom_offer_currency ?? null
typeof intention.custom_offer_amount === "number"
? intention.custom_offer_amount
: null,
intention.custom_offer_currency ?? null,
);
return (
<>
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{itemImageUrl ? (
<Image
@@ -254,160 +284,172 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{listing.server_name}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">
{listing.item_name}
</h3>
{getStatusBadge(intention.status)}
</div>
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>Qtd: {listing.quantity}</span>
</div>
<div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
{price !== null && (
<span className="font-medium text-foreground ml-1">
{formatPrice(price, intention.payment_method)}
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{listing.server_name}</span>
</div>
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>Qtd: {listing.quantity}</span>
</div>
<div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span>
{getPaymentMethodLabel(intention.payment_method)}
</span>
{price !== null && (
<span className="font-medium text-foreground ml-1">
{formatPrice(price, intention.payment_method)}
</span>
)}
</div>
{customOfferLabel && (
<div className="flex items-center gap-1 text-foreground">
<Badge
variant="outline"
className="border-primary text-primary"
>
Sua oferta: {customOfferLabel}
</Badge>
</div>
)}
</div>
{customOfferLabel && (
<div className="flex items-center gap-1 text-foreground">
<Badge variant="outline" className="border-primary text-primary">
Sua oferta: {customOfferLabel}
</Badge>
</div>
)}
</div>
</div>
</div>
</div>
</CardHeader>
</CardHeader>
<CardContent className="space-y-3">
{/* Informações do vendedor */}
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Vendedor:</span>
<span className="font-medium">
{listing.seller_character_name || listing.seller_username || "Desconhecido"}
</span>
</div>
<CardContent className="space-y-3">
{/* Informações do vendedor */}
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Vendedor:</span>
<span className="font-medium">
{listing.seller_character_name ||
listing.seller_username ||
"Desconhecido"}
</span>
</div>
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div className="bg-muted/50 rounded-md p-3">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">Sua mensagem:</p>
<p className="text-sm">{intention.buyer_message}</p>
{/* Mensagem do comprador */}
{intention.buyer_message && (
<div className="bg-muted/50 rounded-md p-3">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">
Sua mensagem:
</p>
<p className="text-sm">{intention.buyer_message}</p>
</div>
</div>
</div>
</div>
)}
)}
{/* Resposta do vendedor */}
{intention.seller_response && (
<div
className={cn(
"rounded-md p-3 border",
intention.status === "accepted"
? "bg-green-500/10 border-green-500/20"
: intention.status === "rejected"
? "bg-red-500/10 border-red-500/20"
: "bg-blue-500/10 border-blue-500/20"
{/* Resposta do vendedor */}
{intention.seller_response && (
<div
className={cn(
"rounded-md p-3 border",
intention.status === "accepted"
? "bg-green-500/10 border-green-500/20"
: intention.status === "rejected"
? "bg-red-500/10 border-red-500/20"
: "bg-blue-500/10 border-blue-500/20",
)}
>
<div className="flex items-start gap-2">
<MessageSquare
className={cn(
"h-4 w-4 mt-0.5 flex-shrink-0",
intention.status === "accepted"
? "text-green-600 dark:text-green-400"
: intention.status === "rejected"
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400",
)}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium mb-1">
{intention.status === "accepted"
? "Resposta do vendedor (Aceita):"
: intention.status === "rejected"
? "Resposta do vendedor (Rejeitada):"
: "Resposta do vendedor:"}
</p>
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Criada {formatTimeAgo(intention.created_at)}</span>
</div>
{intention.updated_at !== intention.created_at && (
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span>
)}
>
<div className="flex items-start gap-2">
<MessageSquare
className={cn(
"h-4 w-4 mt-0.5 flex-shrink-0",
intention.status === "accepted"
? "text-green-600 dark:text-green-400"
: intention.status === "rejected"
? "text-red-600 dark:text-red-400"
: "text-blue-600 dark:text-blue-400"
)}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium mb-1">
{intention.status === "accepted"
? "Resposta do vendedor (Aceita):"
: intention.status === "rejected"
? "Resposta do vendedor (Rejeitada):"
: "Resposta do vendedor:"}
</p>
<p className="text-sm">{intention.seller_response}</p>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Criada {formatTimeAgo(intention.created_at)}</span>
</div>
{intention.updated_at !== intention.created_at && (
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 flex-wrap">
{intention.status === "pending" &&
intention.payment_method_change_count < 1 && (
<ChangePaymentMethodButton
intentionId={intention.intention_id}
currentPaymentMethod={intention.payment_method}
availableMethods={[
...(listing.price_pix ? ["pix"] : []),
...(listing.price_tibia_coins ? ["tibia_coins"] : []),
...(listing.price_gold_coins ? ["gold_coins"] : []),
...(listing.trade_item_id ? ["trade"] : []),
]}
canChange={true}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowListingModal(true)}
className="flex-1"
>
<ExternalLink className="h-4 w-4 mr-2" />
Ver Anúncio
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setShowChatModal(true)}
>
<MessageSquare className="h-4 w-4 mr-2" />
Abrir Chat
</Button>
{intention.status === "accepted" && listing.listing_status === "active" && (
{/* Actions */}
<div className="flex items-center gap-2 pt-2 flex-wrap">
{intention.status === "pending" &&
intention.payment_method_change_count < 1 && (
<ChangePaymentMethodButton
intentionId={intention.intention_id}
currentPaymentMethod={intention.payment_method}
availableMethods={[
...(listing.price_pix ? ["pix"] : []),
...(listing.price_tibia_coins ? ["tibia_coins"] : []),
...(listing.price_gold_coins ? ["gold_coins"] : []),
...(listing.trade_item_id ? ["trade"] : []),
]}
canChange={true}
/>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowListingModal(true)}
className="flex-1"
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar Agora
<ExternalLink className="h-4 w-4 mr-2" />
Ver Anúncio
</Button>
)}
</div>
</CardContent>
</Card>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setShowChatModal(true)}
>
<MessageSquare className="h-4 w-4 mr-2" />
Abrir Chat
</Button>
{intention.status === "accepted" &&
listing.listing_status === "active" && (
<Button
size="sm"
onClick={() => setShowListingModal(true)}
className="flex-1"
>
<ShoppingCart className="h-4 w-4 mr-2" />
Comprar Agora
</Button>
)}
</div>
</CardContent>
</Card>
<Dialog open={showListingModal} onOpenChange={setShowListingModal}>
<DialogContent className="max-w-2xl">
<DialogHeader>
@@ -434,30 +476,48 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{listing.item_name}</h3>
<p className="text-sm text-muted-foreground mt-1">{listing.description ?? "Sem descrição fornecida para este anúncio."}</p>
<p className="text-sm text-muted-foreground mt-1">
{listing.description ??
"Sem descrição fornecida para este anúncio."}
</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>Servidor: <span className="font-medium text-foreground">{listing.server_name}</span></span>
<span>
Servidor:{" "}
<span className="font-medium text-foreground">
{listing.server_name}
</span>
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Package className="h-4 w-4" />
<span>Quantidade: <span className="font-medium text-foreground">{listing.quantity}</span></span>
<span>
Quantidade:{" "}
<span className="font-medium text-foreground">
{listing.quantity}
</span>
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
<span>Vendedor:{" "}
<span>
Vendedor:{" "}
<span className="font-medium text-foreground">
{listing.seller_character_name || listing.seller_username || "Desconhecido"}
{listing.seller_character_name ||
listing.seller_username ||
"Desconhecido"}
</span>
</span>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Condições de pagamento</h4>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Condições de pagamento
</h4>
<div className="flex flex-wrap gap-2">
{listing.price_pix && (
<Badge variant="secondary" className="gap-1">
@@ -477,18 +537,23 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
{formatPrice(listing.price_gold_coins, "gold_coins")}
</Badge>
)}
{listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity && (
<Badge variant="secondary" className="gap-1">
<RefreshCw className="h-3 w-3" />
{listing.trade_item_quantity}x {listing.trade_item_name}
</Badge>
)}
{listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity && (
<Badge variant="secondary" className="gap-1">
<RefreshCw className="h-3 w-3" />
{listing.trade_item_quantity}x {listing.trade_item_name}
</Badge>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/${locale}/market?listing=${listing.listing_id}`} target="_blank">
<Link
href={`/${locale}/market?listing=${listing.listing_id}`}
target="_blank"
>
Abrir no marketplace
</Link>
</Button>
@@ -521,17 +586,25 @@ interface MyPurchaseIntentionsPanelProps {
locale?: string;
}
export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) {
export function MyPurchaseIntentionsPanel({
locale = "pt",
}: MyPurchaseIntentionsPanelProps) {
const { user } = useSession();
const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all");
const { intentions, isLoading } = useMyPurchaseIntentions();
// Contar por status
const pendingCount = intentions.filter((i) => i.status === "pending").length;
const acceptedCount = intentions.filter((i) => i.status === "accepted").length;
const rejectedCount = intentions.filter((i) => i.status === "rejected").length;
const acceptedCount = intentions.filter(
(i) => i.status === "accepted",
).length;
const rejectedCount = intentions.filter(
(i) => i.status === "rejected",
).length;
const expiredCount = intentions.filter((i) => i.status === "expired").length;
const completedCount = intentions.filter((i) => i.status === "completed").length;
const completedCount = intentions.filter(
(i) => i.status === "completed",
).length;
const filteredIntentions =
filter === "all"
? intentions
@@ -540,7 +613,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
if (!user) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Faça login para ver suas intenções de compra</p>
<p className="text-muted-foreground">
Faça login para ver suas intenções de compra
</p>
</div>
);
}
@@ -614,7 +689,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
) : filteredIntentions.length === 0 ? (
<div className="text-center py-12 border rounded-lg">
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-lg font-medium mb-2">Nenhuma intenção de compra encontrada</p>
<p className="text-lg font-medium mb-2">
Nenhuma intenção de compra encontrada
</p>
<p className="text-sm text-muted-foreground mb-4">
{filter === "all"
? "Quando você manifestar interesse em comprar um item, ele aparecerá aqui."
@@ -627,11 +704,14 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
) : (
<div className="space-y-4">
{filteredIntentions.map((intention) => (
<IntentionCard key={intention.intention_id} intention={intention} locale={locale} />
<IntentionCard
key={intention.intention_id}
intention={intention}
locale={locale}
/>
))}
</div>
)}
</div>
);
}
+89 -42
View File
@@ -1,6 +1,9 @@
"use client";
import { Coins, DollarSign, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,13 +11,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { DollarSign, Coins, RefreshCw, Loader2 } from "lucide-react";
import { useSession } from "@/lib/hooks/use-session";
import { Input } from "@/components/ui/input";
interface Listing {
listing_id: number;
@@ -37,7 +37,12 @@ interface PurchaseModalProps {
onSuccess?: () => void;
}
export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: PurchaseModalProps) {
export function PurchaseModal({
open,
onOpenChange,
listing,
onSuccess,
}: PurchaseModalProps) {
const { user } = useSession();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -53,7 +58,10 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}, [open]);
useEffect(() => {
if (selectedMethod && !["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) {
if (
selectedMethod &&
!["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)
) {
setCustomOfferValue("");
}
}, [selectedMethod]);
@@ -61,13 +69,19 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
if (!listing) return null;
// Determinar métodos de pagamento disponíveis
const availableMethods: Array<{ type: string; label: string; value: string; icon: any }> = [];
const availableMethods: Array<{
type: string;
label: string;
value: string;
icon: any;
}> = [];
if (listing.price_pix) {
const price = typeof listing.price_pix === 'string'
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!isNaN(price)) {
const price =
typeof listing.price_pix === "string"
? parseFloat(listing.price_pix)
: listing.price_pix;
if (!Number.isNaN(price)) {
availableMethods.push({
type: "pix",
label: "PIX",
@@ -78,10 +92,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}
if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string'
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!isNaN(price)) {
const price =
typeof listing.price_tibia_coins === "string"
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
if (!Number.isNaN(price)) {
availableMethods.push({
type: "tibia_coins",
label: "Tibia Coins",
@@ -92,10 +107,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}
if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string'
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!isNaN(price)) {
const price =
typeof listing.price_gold_coins === "string"
? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
if (!Number.isNaN(price)) {
availableMethods.push({
type: "gold_coins",
label: "Gold Coins",
@@ -105,7 +121,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
}
}
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
if (
listing.trade_item_id &&
listing.trade_item_name &&
listing.trade_item_quantity
) {
availableMethods.push({
type: "trade",
label: "Troca",
@@ -114,7 +134,10 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
});
}
const formatOffer = (amount: number | string | null, currency: string | null) => {
const formatOffer = (
amount: number | string | null,
currency: string | null,
) => {
if (amount === null || amount === undefined || !currency) {
return null;
}
@@ -136,7 +159,7 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
const highestOfferLabel = formatOffer(
listing.highest_custom_offer_amount ?? null,
listing.highest_custom_offer_currency ?? null
listing.highest_custom_offer_currency ?? null,
);
// Se houver apenas um método, selecionar automaticamente
@@ -167,20 +190,28 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
payment_method: selectedMethod,
};
if (hasCustomOffer && ["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) {
if (
hasCustomOffer &&
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)
) {
const offerNumber = Number(customOfferValue);
payload.custom_offer_amount =
selectedMethod === "pix" ? Number(offerNumber.toFixed(2)) : Math.floor(offerNumber);
selectedMethod === "pix"
? Number(offerNumber.toFixed(2))
: Math.floor(offerNumber);
payload.custom_offer_currency = selectedMethod;
}
const response = await fetch(`/api/listings/${listing.listing_id}/purchase`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`/api/listings/${listing.listing_id}/purchase`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
body: JSON.stringify(payload),
});
);
const data = await response.json();
@@ -236,10 +267,16 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Valores anunciados pelo vendedor</p>
<p className="text-sm font-medium">
Valores anunciados pelo vendedor
</p>
<div className="flex flex-wrap gap-2">
{availableMethods.map((method) => (
<Badge key={`original-${method.type}`} variant="secondary" className="gap-1">
<Badge
key={`original-${method.type}`}
variant="secondary"
className="gap-1"
>
{method.label}: {method.value}
</Badge>
))}
@@ -287,15 +324,21 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
) : availableMethods.length === 1 ? (
<div className="space-y-2">
<Label className="text-sm font-medium">Método de Pagamento</Label>
<div className={`p-4 rounded-lg border-2 ${getMethodClassName(availableMethods[0].type)}`}>
<div
className={`p-4 rounded-lg border-2 ${getMethodClassName(availableMethods[0].type)}`}
>
<div className="flex items-center gap-2 mb-1">
{(() => {
const Icon = availableMethods[0].icon;
return <Icon className="h-4 w-4" />;
})()}
<span className="font-medium">{availableMethods[0].label}</span>
<span className="font-medium">
{availableMethods[0].label}
</span>
</div>
<p className="text-sm opacity-90">{availableMethods[0].value}</p>
<p className="text-sm opacity-90">
{availableMethods[0].value}
</p>
</div>
</div>
) : (
@@ -308,7 +351,8 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod) && (
<div className="space-y-2">
<Label className="text-sm font-medium">
Propor valor personalizado ({selectedMethod === "pix" ? "R$" : "quantidade"})
Propor valor personalizado (
{selectedMethod === "pix" ? "R$" : "quantidade"})
</Label>
<Input
type="number"
@@ -316,11 +360,13 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
step={selectedMethod === "pix" ? "0.01" : "1"}
value={customOfferValue}
onChange={(event) => setCustomOfferValue(event.target.value)}
placeholder={selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"}
placeholder={
selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"
}
/>
<p className="text-xs text-muted-foreground">
Esse valor será enviado ao vendedor como sua proposta. Ele pode aceitar, negar ou
responder.
Esse valor será enviado ao vendedor como sua proposta. Ele
pode aceitar, negar ou responder.
</p>
</div>
)}
@@ -344,7 +390,9 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
</Button>
<Button
onClick={handlePurchase}
disabled={loading || !selectedMethod || availableMethods.length === 0}
disabled={
loading || !selectedMethod || availableMethods.length === 0
}
>
{loading ? (
<>
@@ -361,4 +409,3 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
</Dialog>
);
}
+6 -11
View File
@@ -1,9 +1,9 @@
"use client";
import { CheckCircle2, User } from "lucide-react";
import { useEffect } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { User, CheckCircle2 } from "lucide-react";
import { useEffect } from "react";
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
interface SellerInfoProps {
@@ -27,14 +27,10 @@ export function SellerInfo({
// Tracking de visualização de vendedor
useEffect(() => {
trackCustomEvent(
"seller_profile_viewed",
listingId,
{
seller_id: sellerId,
seller_name: sellerName,
}
);
trackCustomEvent("seller_profile_viewed", listingId, {
seller_id: sellerId,
seller_name: sellerName,
});
}, [sellerId, sellerName, listingId, trackCustomEvent]);
// Gerar iniciais do nome
@@ -75,4 +71,3 @@ export function SellerInfo({
</div>
);
}
+9 -8
View File
@@ -1,10 +1,10 @@
"use client";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Package, Clock } from "lucide-react";
import { Clock, Package } from "lucide-react";
import Image from "next/image";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import type { Chat } from "@/lib/hooks/use-chat";
import { formatTimeAgo } from "@/lib/utils";
@@ -20,9 +20,7 @@ export function ChatListItem({
onChatClick,
}: ChatListItemProps) {
const otherUser =
chat.buyer_id === currentUserId
? chat.seller_cache
: chat.buyer_cache;
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
const unreadCount =
chat.buyer_id === currentUserId
@@ -53,7 +51,10 @@ export function ChatListItem({
{otherUser.character_name || otherUser.username}
</h4>
{unreadCount > 0 && (
<Badge variant="destructive" className="h-5 min-w-5 px-1.5 text-xs">
<Badge
variant="destructive"
className="h-5 min-w-5 px-1.5 text-xs"
>
{unreadCount}
</Badge>
)}
+5 -5
View File
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useSession } from "@/lib/hooks/use-session";
import { MessagesPanel } from "./MessagesPanel";
@@ -46,14 +46,14 @@ export function MessagesBell({ className }: MessagesBellProps) {
}, 30000); // 30 segundos
return () => clearInterval(interval);
}, [user]);
}, [user, fetchUnreadCount]);
// Atualizar contagem quando abrir/fechar
useEffect(() => {
if (isOpen) {
fetchUnreadCount();
}
}, [isOpen]);
}, [isOpen, fetchUnreadCount]);
// Marcar como visto quando abrir
const handleOpenChange = (open: boolean) => {
@@ -82,7 +82,7 @@ export function MessagesBell({ className }: MessagesBellProps) {
</Badge>
)}
</Button>
<MessagesPanel
open={isOpen}
onOpenChange={handleOpenChange}
+23 -15
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { ChatModal } from "@/components/chat/ChatModal";
import {
Sheet,
SheetContent,
@@ -8,11 +9,10 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ChatListItem } from "./ChatListItem";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session";
import { ChatModal } from "@/components/chat/ChatModal";
import type { Chat } from "@/lib/hooks/use-chat";
import { useSession } from "@/lib/hooks/use-session";
import { ChatListItem } from "./ChatListItem";
interface MessagesPanelProps {
open: boolean;
@@ -44,7 +44,7 @@ export function MessagesPanel({
const data = await res.json();
if (data.success) {
setChats(data.chats || []);
// Calcular contagem de usuários únicos com não lidas
const uniqueUsersWithUnread = new Set<number>();
data.chats.forEach((chat: Chat) => {
@@ -53,15 +53,13 @@ export function MessagesPanel({
? chat.buyer_unread_count
: chat.seller_unread_count;
const otherUserId =
chat.buyer_id === user.userId
? chat.seller_id
: chat.buyer_id;
chat.buyer_id === user.userId ? chat.seller_id : chat.buyer_id;
if (unreadCount > 0) {
uniqueUsersWithUnread.add(otherUserId);
}
});
onUnreadCountChange(uniqueUsersWithUnread.size);
}
}
@@ -78,7 +76,12 @@ export function MessagesPanel({
// Marcar todos como lidos ao abrir
markAllAsRead();
}
}, [open, user]);
}, [
open,
user,
fetchChats, // Marcar todos como lidos ao abrir
markAllAsRead,
]);
const markAllAsRead = async () => {
try {
@@ -94,7 +97,7 @@ export function MessagesPanel({
chat.buyer_id === user?.userId ? 0 : chat.buyer_unread_count,
seller_unread_count:
chat.seller_id === user?.userId ? 0 : chat.seller_unread_count,
}))
})),
);
onUnreadCountChange(0);
} catch (error) {
@@ -102,7 +105,7 @@ export function MessagesPanel({
}
};
const handleChatClick = (chatId: string, listingId: number) => {
const handleChatClick = (chatId: string, _listingId: number) => {
setSelectedChatId(chatId);
setShowChatModal(true);
onOpenChange(false);
@@ -123,7 +126,10 @@ export function MessagesPanel({
</SheetDescription>
</SheetHeader>
<div className="mt-4 px-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 120px)" }}>
<div
className="mt-4 px-4 overflow-y-auto"
style={{ maxHeight: "calc(100vh - 120px)" }}
>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
@@ -144,7 +150,9 @@ export function MessagesPanel({
key={chat.chat_id}
chat={chat}
currentUserId={user.userId}
onChatClick={() => handleChatClick(chat.chat_id, chat.listing_id)}
onChatClick={() =>
handleChatClick(chat.chat_id, chat.listing_id)
}
/>
))}
</div>
+5 -10
View File
@@ -1,14 +1,14 @@
"use client";
import { Menu } from "lucide-react";
import { useState, useEffect } from "react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { LanguageSwitcher } from "./language-switcher";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";
interface MobileMenuProps {
links: Array<{ href: string; label: string }>;
@@ -77,8 +77,3 @@ export function MobileMenu({ links }: MobileMenuProps) {
</Sheet>
);
}
+10 -11
View File
@@ -1,16 +1,16 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDropdown } from "./NotificationDropdown";
import { useSession } from "@/lib/hooks/use-session";
import { NotificationDropdown } from "./NotificationDropdown";
interface Notification {
notification_id: number;
@@ -32,7 +32,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [_loading, setLoading] = useState(false);
const rawUnreadRef = useRef(0);
const baselineUnreadRef = useRef(0);
const baselineLoadedRef = useRef(false);
@@ -122,7 +122,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
baselineUnreadRef.current = 0;
baselineLoadedRef.current = false;
}
}, [effectiveUserId]);
}, [effectiveUserId, loadBaselineFromStorage]);
useEffect(() => {
if (!effectiveUserId) return;
@@ -138,7 +138,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
}, 30000); // 30 segundos
return () => clearInterval(interval);
}, [effectiveUserId, isOpen]);
}, [effectiveUserId, isOpen, fetchNotifications, fetchUnreadCount]);
// Atualizar quando abrir popover
useEffect(() => {
@@ -147,7 +147,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
persistBaseline(rawUnreadRef.current);
setUnreadCount(0);
}
}, [isOpen]);
}, [isOpen, fetchNotifications, persistBaseline]);
const handleMarkAsRead = async (notificationId: number) => {
try {
@@ -159,14 +159,14 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
// Atualizar estado local
setNotifications((prev) =>
prev.map((n) =>
n.notification_id === notificationId ? { ...n, is_read: true } : n
)
n.notification_id === notificationId ? { ...n, is_read: true } : n,
),
);
// Atualizar contagem
rawUnreadRef.current = Math.max(0, rawUnreadRef.current - 1);
const newBaseline = Math.min(
baselineUnreadRef.current,
rawUnreadRef.current
rawUnreadRef.current,
);
persistBaseline(newBaseline);
setUnreadCount((prev) => Math.max(0, prev - 1));
@@ -209,4 +209,3 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
</DropdownMenu>
);
}
@@ -1,11 +1,17 @@
"use client";
import {
Bell,
ExternalLink,
MessageSquare,
ShoppingCart,
TrendingUp,
} from "lucide-react";
import Link from "next/link";
import { useLocale } from "next-intl";
import { Button } from "@/components/ui/button";
// ScrollArea não disponível, usando div com overflow
import { Separator } from "@/components/ui/separator";
import { Bell, ShoppingCart, TrendingUp, MessageSquare, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useLocale } from "next-intl";
interface Notification {
notification_id: number;
@@ -24,10 +30,11 @@ interface NotificationDropdownProps {
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
if (minutes < 60)
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24);
@@ -118,14 +125,16 @@ export function NotificationDropdown({
<div className="flex items-start gap-3">
<div
className={`${getNotificationColor(
notification.notification_type
notification.notification_type,
)} rounded-full p-2 text-white flex-shrink-0`}
>
{getNotificationIcon(notification.notification_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="font-medium text-sm">{notification.title}</h4>
<h4 className="font-medium text-sm">
{notification.title}
</h4>
{!notification.is_read && (
<div className="h-2 w-2 bg-primary rounded-full flex-shrink-0 mt-1" />
)}
@@ -148,7 +157,9 @@ export function NotificationDropdown({
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator className="my-2" />}
{index < notifications.length - 1 && (
<Separator className="my-2" />
)}
</div>
))}
</div>
@@ -156,4 +167,3 @@ export function NotificationDropdown({
</div>
);
}
+7 -19
View File
@@ -1,24 +1,13 @@
"use client";
import { MarketOffer } from "@/types/item";
import { Clock, Coins, Heart, Shield, Sword } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
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";
import type { MarketOffer } from "@/types/item";
interface OfferCardProps {
offer: MarketOffer;
@@ -28,7 +17,7 @@ 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 [_isHovered, setIsHovered] = useState(false);
const formatTimeAgo = (timestamp: Date) => {
const now = new Date();
@@ -81,8 +70,7 @@ export function OfferCard({ offer }: OfferCardProps) {
};
const isHotDeal = offer.price < offer.item.priceHistory.average * 0.8;
const isNewOffer =
new Date().getTime() - offer.timestamp.getTime() < 1000 * 60 * 30; // 30 min
const isNewOffer = Date.now() - offer.timestamp.getTime() < 1000 * 60 * 30; // 30 min
return (
<div
@@ -128,7 +116,7 @@ export function OfferCard({ offer }: OfferCardProps) {
</div>
<Badge
className={`${getRarityColor(
offer.item.rarity
offer.item.rarity,
)} text-white absolute -top-2 -right-2 text-xs shadow-lg`}
>
{offer.item.rarity}
@@ -1,5 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -9,6 +11,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -16,9 +19,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { RefreshCw, Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
interface ChangePaymentMethodButtonProps {
intentionId: number;
@@ -49,14 +49,15 @@ export function ChangePaymentMethodButton({
canChange,
}: ChangePaymentMethodButtonProps) {
const [open, setOpen] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<string>(currentPaymentMethod);
const [selectedMethod, setSelectedMethod] =
useState<string>(currentPaymentMethod);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Filtrar métodos disponíveis (excluir o atual)
const availableOptions = availableMethods.filter(
(method) => method !== currentPaymentMethod
(method) => method !== currentPaymentMethod,
);
const handleChange = async () => {
@@ -75,7 +76,7 @@ export function ChangePaymentMethodButton({
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_method: selectedMethod }),
}
},
);
const data = await res.json();
@@ -92,7 +93,11 @@ export function ChangePaymentMethodButton({
setOpen(false);
// Opcional: mostrar toast de sucesso
} catch (err) {
setError(err instanceof Error ? err.message : "Erro ao trocar método de pagamento");
setError(
err instanceof Error
? err.message
: "Erro ao trocar método de pagamento",
);
} finally {
setLoading(false);
}
@@ -123,7 +128,8 @@ export function ChangePaymentMethodButton({
<DialogHeader>
<DialogTitle>Trocar Método de Pagamento</DialogTitle>
<DialogDescription>
Você pode trocar o método de pagamento apenas uma vez. Escolha o novo método:
Você pode trocar o método de pagamento apenas uma vez. Escolha o
novo método:
</DialogDescription>
</DialogHeader>
@@ -158,10 +164,17 @@ export function ChangePaymentMethodButton({
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
>
Cancelar
</Button>
<Button onClick={handleChange} disabled={loading || selectedMethod === currentPaymentMethod}>
<Button
onClick={handleChange}
disabled={loading || selectedMethod === currentPaymentMethod}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -178,4 +191,3 @@ export function ChangePaymentMethodButton({
</>
);
}
+1 -1
View File
@@ -1,8 +1,8 @@
"use client";
import { Coins, Shield } from "lucide-react";
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");
+12 -12
View File
@@ -1,26 +1,26 @@
"use client";
import {
BarChart,
CheckCircle,
Handshake,
Lock,
Shield,
Wallet,
Zap,
} from "lucide-react";
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 { Badge } from "@/components/ui/badge";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
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");
+9 -8
View File
@@ -1,12 +1,12 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
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";
import { featuredItems } from "@/lib/mock-data";
export function FeaturedItems() {
const t = useTranslations("home.featured");
@@ -21,7 +21,7 @@ export function FeaturedItems() {
priceRange: { min: 0, max: 100000 },
});
const [sortBy, setSortBy] = useState("name");
const [isLoading, setIsLoading] = useState(false);
const [isLoading, _setIsLoading] = useState(false);
// Filtrar e ordenar itens
const filteredItems = useMemo(() => {
@@ -30,21 +30,21 @@ export function FeaturedItems() {
// Filtro por busca
if (searchQuery) {
filtered = filtered.filter((item) =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
item.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
}
// Filtro por raridade
if (filters.rarity.length > 0) {
filtered = filtered.filter((item) =>
filters.rarity.includes(item.rarity)
filters.rarity.includes(item.rarity),
);
}
// Filtro por categoria
if (filters.category.length > 0) {
filtered = filtered.filter((item) =>
filters.category.includes(item.category)
filters.category.includes(item.category),
);
}
@@ -52,7 +52,7 @@ export function FeaturedItems() {
filtered = filtered.filter(
(item) =>
item.price.buy >= filters.priceRange.min &&
item.price.buy <= filters.priceRange.max
item.price.buy <= filters.priceRange.max,
);
// Ordenação
@@ -64,7 +64,7 @@ export function FeaturedItems() {
return a.price.buy - b.price.buy;
case "price-desc":
return b.price.buy - a.price.buy;
case "rarity":
case "rarity": {
const rarityOrder = {
common: 0,
uncommon: 1,
@@ -73,6 +73,7 @@ export function FeaturedItems() {
legendary: 4,
};
return rarityOrder[a.rarity] - rarityOrder[b.rarity];
}
case "trending":
return b.priceHistory.highest - a.priceHistory.highest;
case "recent":
+2 -2
View File
@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
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";
import { ShimmerButton } from "@/components/ui/shimmer-button";
export function HeroSection() {
const t = useTranslations("home.hero");
+2 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { Coins, Shield, ShoppingBag } from "lucide-react";
import { useTranslations } from "next-intl";
import { BentoGrid, BentoCard } from "@/components/ui/bento-grid";
import { ShoppingBag, Coins, Shield } from "lucide-react";
import { BentoCard, BentoGrid } from "@/components/ui/bento-grid";
export function HowItWorks() {
const t = useTranslations("home.howItWorks");
+5 -11
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { ItemCard } from "@/components/item-card";
import { BlurFade } from "@/components/ui/blur-fade";
import { Skeleton } from "@/components/ui/skeleton";
@@ -38,7 +38,7 @@ export function MarketExample() {
// Buscar mix de categorias para variedade visual
const categories = ["weapon", "armor", "helmet"];
const response = await fetch(
`/api/items?category=${categories.join(",")}&pageSize=8&sort=name`
`/api/items?category=${categories.join(",")}&pageSize=8&sort=name`,
);
if (!response.ok) {
@@ -116,9 +116,7 @@ export function MarketExample() {
<div className="container">
<BlurFade delay={0.2}>
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4">
{t("title")}
</h2>
<h2 className="text-4xl font-bold mb-4">{t("title")}</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t("subtitle")}
</p>
@@ -131,9 +129,7 @@ export function MarketExample() {
) : error ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">⚠️</div>
<h3 className="text-xl font-semibold mb-2">
{t("errorTitle")}
</h3>
<h3 className="text-xl font-semibold mb-2">{t("errorTitle")}</h3>
<p className="text-muted-foreground">{error}</p>
</div>
) : (
@@ -153,9 +149,7 @@ export function MarketExample() {
<h3 className="text-xl font-semibold mb-2">
Nenhum item encontrado
</h3>
<p className="text-muted-foreground">
Tente novamente mais tarde
</p>
<p className="text-muted-foreground">Tente novamente mais tarde</p>
</div>
)}
</div>
+2 -2
View File
@@ -1,10 +1,10 @@
"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 { AnimatedList } from "@/components/ui/animated-list";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { buyOffers, sellOffers } from "@/lib/mock-data";
export function MarketOffers() {
const t = useTranslations("home.offers");
+10 -10
View File
@@ -1,17 +1,17 @@
"use client";
import { useTranslations } from "next-intl";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Badge } from "@/components/ui/badge";
import {
ArrowDown,
ArrowUp,
DollarSign,
ShoppingCart,
TrendingUp,
Users,
DollarSign,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { AreaChart, Area, ResponsiveContainer } from "recharts";
import { useTranslations } from "next-intl";
import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { NumberTicker } from "@/components/ui/number-ticker";
import { buyOffers, sellOffers } from "@/lib/mock-data";
interface StatData {
@@ -130,14 +130,14 @@ export function MarketStats() {
const t = useTranslations("home.stats");
// Calcular volume total baseado nos dados mock
const calculateTotalVolume = () => {
const _calculateTotalVolume = () => {
const buyVolume = buyOffers.reduce(
(sum, offer) => sum + offer.price * offer.quantity,
0
0,
);
const sellVolume = sellOffers.reduce(
(sum, offer) => sum + offer.price * offer.quantity,
0
0,
);
return Math.round((buyVolume + sellVolume) / 1000); // Dividir por 1000 para número mais legível
};
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ComponentProps } from "react";
import type { ComponentProps } from "react";
type ThemeProviderProps = ComponentProps<typeof NextThemesProvider>;
+3 -8
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -10,7 +11,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
@@ -58,8 +58,3 @@ export function ThemeToggle() {
</DropdownMenu>
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
@@ -28,7 +28,7 @@ const AccordionTrigger = React.forwardRef<
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
className,
)}
{...props}
>
+7 -7
View File
@@ -1,12 +1,12 @@
import { ComponentPropsWithoutRef } from "react"
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
export interface AnimatedGradientTextProps
extends ComponentPropsWithoutRef<"div"> {
speed?: number
colorFrom?: string
colorTo?: string
speed?: number;
colorFrom?: string;
colorTo?: string;
}
export function AnimatedGradientText({
@@ -28,11 +28,11 @@ export function AnimatedGradientText({
}
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
className,
)}
{...props}
>
{children}
</span>
)
);
}
+23 -23
View File
@@ -1,14 +1,14 @@
"use client"
"use client";
import { AnimatePresence, type MotionProps, motion } from "motion/react";
import React, {
ComponentPropsWithoutRef,
type ComponentPropsWithoutRef,
useEffect,
useMemo,
useState,
} from "react"
import { AnimatePresence, motion, MotionProps } from "motion/react"
} from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
export function AnimatedListItem({ children }: { children: React.ReactNode }) {
const animations: MotionProps = {
@@ -16,42 +16,42 @@ export function AnimatedListItem({ children }: { children: React.ReactNode }) {
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
children: React.ReactNode;
delay?: number;
}
export const AnimatedList = React.memo(
({ children, className, delay = 1000, ...props }: AnimatedListProps) => {
const [index, setIndex] = useState(0)
const [index, setIndex] = useState(0);
const childrenArray = useMemo(
() => React.Children.toArray(children),
[children]
)
[children],
);
useEffect(() => {
if (index < childrenArray.length - 1) {
const timeout = setTimeout(() => {
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length)
}, delay)
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length);
}, delay);
return () => clearTimeout(timeout)
return () => clearTimeout(timeout);
}
}, [index, delay, childrenArray.length])
}, [index, delay, childrenArray.length]);
const itemsToShow = useMemo(() => {
const result = childrenArray.slice(0, index + 1).reverse()
return result
}, [index, childrenArray])
const result = childrenArray.slice(0, index + 1).reverse();
return result;
}, [index, childrenArray]);
return (
<div
@@ -66,8 +66,8 @@ export const AnimatedList = React.memo(
))}
</AnimatePresence>
</div>
)
}
)
);
},
);
AnimatedList.displayName = "AnimatedList"
AnimatedList.displayName = "AnimatedList";
+10 -10
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Avatar({
className,
@@ -14,11 +14,11 @@ function Avatar({
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
className,
)}
{...props}
/>
)
);
}
function AvatarImage({
@@ -31,7 +31,7 @@ function AvatarImage({
className={cn("aspect-square size-full", className)}
{...props}
/>
)
);
}
function AvatarFallback({
@@ -43,11 +43,11 @@ function AvatarFallback({
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
className,
)}
{...props}
/>
)
);
}
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"
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",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -32,7 +32,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+4 -5
View File
@@ -1,8 +1,7 @@
import { ComponentPropsWithoutRef, ReactNode } from "react";
import { ArrowRightIcon } from "@radix-ui/react-icons";
import { cn } from "@/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface BentoGridProps extends ComponentPropsWithoutRef<"div"> {
children: ReactNode;
@@ -26,7 +25,7 @@ const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
"grid w-full gap-6",
"grid-cols-1 md:grid-cols-3",
"auto-rows-[minmax(280px,auto)]",
className
className,
)}
style={{
gridTemplateColumns: "1fr 2fr",
@@ -60,7 +59,7 @@ const BentoCard = ({
// dark styles
"dark:bg-background dark:border-border/50",
"dark:shadow-sm dark:hover:shadow-xl",
className
className,
)}
{...props}
>
+25 -25
View File
@@ -1,31 +1,31 @@
"use client"
"use client";
import { useRef } from "react"
import {
AnimatePresence,
type MotionProps,
motion,
MotionProps,
type UseInViewOptions,
useInView,
UseInViewOptions,
Variants,
} from "motion/react"
type Variants,
} from "motion/react";
import { useRef } from "react";
type MarginType = UseInViewOptions["margin"]
type MarginType = UseInViewOptions["margin"];
interface BlurFadeProps extends MotionProps {
children: React.ReactNode
className?: string
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
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({
@@ -41,9 +41,9 @@ export function BlurFade({
blur = "6px",
...props
}: BlurFadeProps) {
const ref = useRef(null)
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
const isInView = !inView || inViewResult
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"]:
@@ -56,8 +56,8 @@ export function BlurFade({
opacity: 1,
filter: `blur(0px)`,
},
}
const combinedVariants = variant || defaultVariants
};
const combinedVariants = variant || defaultVariants;
return (
<AnimatePresence>
<motion.div
@@ -77,5 +77,5 @@ export function BlurFade({
{children}
</motion.div>
</AnimatePresence>
)
);
}
+10 -10
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"
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",
@@ -33,8 +33,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -44,9 +44,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -54,7 +54,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+13 -13
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"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
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};
+9 -10
View File
@@ -1,10 +1,10 @@
"use client"
"use client";
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -14,7 +14,7 @@ const Checkbox = React.forwardRef<
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
className,
)}
{...props}
>
@@ -24,8 +24,7 @@ const Checkbox = React.forwardRef<
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+6 -6
View File
@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
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
className,
)}
{...props}
/>
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
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
className,
)}
{...props}
>
@@ -60,7 +60,7 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
className,
)}
{...props}
/>
@@ -74,7 +74,7 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
@@ -89,7 +89,7 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
className,
)}
{...props}
/>
+33 -32
View File
@@ -1,9 +1,10 @@
"use client"
"use client";
import React, { useEffect, useId, useRef, useState } from "react"
import { motion } from "motion/react"
import { motion } from "motion/react";
import type React from "react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
/**
* DotPattern Component Props
@@ -19,16 +20,16 @@ import { cn } from "@/lib/utils"
* @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
width?: number;
height?: number;
x?: number;
y?: number;
cx?: number;
cy?: number;
cr?: number;
className?: string;
glow?: boolean;
[key: string]: unknown;
}
/**
@@ -73,22 +74,22 @@ export function DotPattern({
glow = false,
...props
}: DotPatternProps) {
const id = useId()
const containerRef = useRef<SVGSVGElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
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 })
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
}
};
updateDimensions()
window.addEventListener("resize", updateDimensions)
return () => window.removeEventListener("resize", updateDimensions)
}, [])
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
const dots = Array.from(
{
@@ -97,16 +98,16 @@ export function DotPattern({
Math.ceil(dimensions.height / height),
},
(_, i) => {
const col = i % Math.ceil(dimensions.width / width)
const row = Math.floor(i / Math.ceil(dimensions.width / width))
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
@@ -114,7 +115,7 @@ export function DotPattern({
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full text-neutral-400/80",
className
className,
)}
{...props}
>
@@ -124,7 +125,7 @@ export function DotPattern({
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</radialGradient>
</defs>
{dots.map((dot, index) => (
{dots.map((dot, _index) => (
<motion.circle
key={`${dot.x}-${dot.y}`}
cx={dot.x}
@@ -154,5 +155,5 @@ export function DotPattern({
/>
))}
</svg>
)
);
}
+33 -38
View File
@@ -1,20 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
{...props}
/>
);
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -22,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -33,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -48,12 +43,12 @@ function DropdownMenuContent({
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
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -61,7 +56,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@@ -70,8 +65,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -80,11 +75,11 @@ function DropdownMenuItem({
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
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -98,7 +93,7 @@ function DropdownMenuCheckboxItem({
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
className,
)}
checked={checked}
{...props}
@@ -110,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -121,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -134,7 +129,7 @@ function DropdownMenuRadioItem({
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
className,
)}
{...props}
>
@@ -145,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -153,7 +148,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@@ -161,11 +156,11 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -178,7 +173,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -190,17 +185,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -209,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -217,14 +212,14 @@ function DropdownMenuSubTrigger({
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
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -236,11 +231,11 @@ function DropdownMenuSubContent({
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
className,
)}
{...props}
/>
)
);
}
export {
@@ -259,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};
+42 -42
View File
@@ -1,12 +1,12 @@
"use client"
"use client";
import { useEffect, useRef } from "react"
import createGlobe, { COBEOptions } from "cobe"
import { useMotionValue, useSpring } from "motion/react"
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const MOVEMENT_DAMPING = 1400
const MOVEMENT_DAMPING = 1400;
const GLOBE_CONFIG: COBEOptions = {
width: 800,
@@ -34,87 +34,87 @@ const GLOBE_CONFIG: COBEOptions = {
{ 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
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)
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 r = useMotionValue(0);
const rs = useSpring(r, {
mass: 1,
damping: 30,
stiffness: 100,
})
});
const updatePointerInteraction = (value: number | null) => {
pointerInteracting.current = value
pointerInteracting.current = value;
if (canvasRef.current) {
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab"
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)
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
width = canvasRef.current.offsetWidth;
}
}
};
window.addEventListener("resize", onResize)
onResize()
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
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)
setTimeout(() => (canvasRef.current?.style.opacity = "1"), 0);
return () => {
globe.destroy()
window.removeEventListener("resize", onResize)
}
}, [rs, config])
globe.destroy();
window.removeEventListener("resize", onResize);
};
}, [rs, config, phi, width]);
return (
<div
className={cn(
"absolute inset-0 mx-auto aspect-[1/1] w-full max-w-[600px]",
className
className,
)}
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]",
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX
updatePointerInteraction(e.clientX)
pointerInteracting.current = e.clientX;
updatePointerInteraction(e.clientX);
}}
onPointerUp={() => updatePointerInteraction(null)}
onPointerOut={() => updatePointerInteraction(null)}
@@ -124,5 +124,5 @@ export function Globe({
}
/>
</div>
)
);
}
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"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
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };
+2 -3
View File
@@ -1,12 +1,12 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
@@ -23,4 +23,3 @@ const Label = React.forwardRef<
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+37 -36
View File
@@ -1,18 +1,19 @@
"use client"
"use client";
import React, { useCallback, useEffect } from "react"
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
import { motion, useMotionTemplate, useMotionValue } from "motion/react";
import type React from "react";
import { useCallback, useEffect } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface MagicCardProps {
children?: React.ReactNode
className?: string
gradientSize?: number
gradientColor?: string
gradientOpacity?: number
gradientFrom?: string
gradientTo?: string
children?: React.ReactNode;
className?: string;
gradientSize?: number;
gradientColor?: string;
gradientOpacity?: number;
gradientFrom?: string;
gradientTo?: string;
}
export function MagicCard({
@@ -24,49 +25,49 @@ export function MagicCard({
gradientFrom = "#9E7AFF",
gradientTo = "#FE8BBB",
}: MagicCardProps) {
const mouseX = useMotionValue(-gradientSize)
const mouseY = useMotionValue(-gradientSize)
const mouseX = useMotionValue(-gradientSize);
const mouseY = useMotionValue(-gradientSize);
const reset = useCallback(() => {
mouseX.set(-gradientSize)
mouseY.set(-gradientSize)
}, [gradientSize, mouseX, mouseY])
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)
const rect = e.currentTarget.getBoundingClientRect();
mouseX.set(e.clientX - rect.left);
mouseY.set(e.clientY - rect.top);
},
[mouseX, mouseY]
)
[mouseX, mouseY],
);
useEffect(() => {
reset()
}, [reset])
reset();
}, [reset]);
useEffect(() => {
const handleGlobalPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) {
reset()
reset();
}
}
};
const handleVisibility = () => {
if (document.visibilityState !== "visible") {
reset()
reset();
}
}
};
window.addEventListener("pointerout", handleGlobalPointerOut)
window.addEventListener("blur", reset)
document.addEventListener("visibilitychange", handleVisibility)
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])
window.removeEventListener("pointerout", handleGlobalPointerOut);
window.removeEventListener("blur", reset);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [reset]);
return (
<div
@@ -99,5 +100,5 @@ export function MagicCard({
/>
<div className="relative">{children}</div>
</div>
)
);
}
+61 -61
View File
@@ -1,103 +1,103 @@
"use client"
"use client";
import { useCallback, useEffect, useRef } from "react"
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const morphTime = 1.5
const cooldownTime = 0.5
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 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 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
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}%`
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
current2.style.opacity = `${fraction ** 0.4 * 100}%`;
const invertedFraction = 1 - fraction
const invertedFraction = 1 - fraction;
current1.style.filter = `blur(${Math.min(
8 / invertedFraction - 8,
100
)}px)`
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`
100,
)}px)`;
current1.style.opacity = `${invertedFraction ** 0.4 * 100}%`;
current1.textContent = texts[textIndexRef.current % texts.length]
current2.textContent = texts[(textIndexRef.current + 1) % texts.length]
current1.textContent = texts[textIndexRef.current % texts.length];
current2.textContent = texts[(textIndexRef.current + 1) % texts.length];
},
[texts]
)
[texts],
);
const doMorph = useCallback(() => {
morphRef.current -= cooldownRef.current
cooldownRef.current = 0
morphRef.current -= cooldownRef.current;
cooldownRef.current = 0;
let fraction = morphRef.current / morphTime
let fraction = morphRef.current / morphTime;
if (fraction > 1) {
cooldownRef.current = cooldownTime
fraction = 1
cooldownRef.current = cooldownTime;
fraction = 1;
}
setStyles(fraction)
setStyles(fraction);
if (fraction === 1) {
textIndexRef.current++
textIndexRef.current++;
}
}, [setStyles])
}, [setStyles]);
const doCooldown = useCallback(() => {
morphRef.current = 0
const [current1, current2] = [text1Ref.current, text2Ref.current]
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%"
current2.style.filter = "none";
current2.style.opacity = "100%";
current1.style.filter = "none";
current1.style.opacity = "0%";
}
}, [])
}, []);
useEffect(() => {
let animationFrameId: number
let animationFrameId: number;
const animate = () => {
animationFrameId = requestAnimationFrame(animate)
animationFrameId = requestAnimationFrame(animate);
const newTime = new Date()
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000
timeRef.current = newTime
const newTime = new Date();
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000;
timeRef.current = newTime;
cooldownRef.current -= dt
cooldownRef.current -= dt;
if (cooldownRef.current <= 0) doMorph()
else doCooldown()
}
if (cooldownRef.current <= 0) doMorph();
else doCooldown();
};
animate()
animate();
return () => {
cancelAnimationFrame(animationFrameId)
}
}, [doMorph, doCooldown])
cancelAnimationFrame(animationFrameId);
};
}, [doMorph, doCooldown]);
return { text1Ref, text2Ref }
}
return { text1Ref, text2Ref };
};
interface MorphingTextProps {
className?: string
texts: string[]
className?: string;
texts: string[];
}
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
const { text1Ref, text2Ref } = useMorphingText(texts)
const { text1Ref, text2Ref } = useMorphingText(texts);
return (
<>
<span
@@ -109,8 +109,8 @@ const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
ref={text2Ref}
/>
</>
)
}
);
};
const SvgFilters: React.FC = () => (
<svg
@@ -131,7 +131,7 @@ const SvgFilters: React.FC = () => (
</filter>
</defs>
</svg>
)
);
export const MorphingText: React.FC<MorphingTextProps> = ({
texts,
@@ -140,10 +140,10 @@ export const MorphingText: React.FC<MorphingTextProps> = ({
<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
className,
)}
>
<Texts texts={texts} />
<SvgFilters />
</div>
)
);
+24 -24
View File
@@ -1,9 +1,9 @@
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 * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
@@ -11,7 +11,7 @@ function NavigationMenu({
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
@@ -19,14 +19,14 @@ function NavigationMenu({
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
);
}
function NavigationMenuList({
@@ -38,11 +38,11 @@ function NavigationMenuList({
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
className,
)}
{...props}
/>
)
);
}
function NavigationMenuItem({
@@ -55,12 +55,12 @@ function NavigationMenuItem({
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"
)
"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,
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
);
}
function NavigationMenuContent({
@@ -92,11 +92,11 @@ function NavigationMenuContent({
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
className,
)}
{...props}
/>
)
);
}
function NavigationMenuViewport({
@@ -106,19 +106,19 @@ function NavigationMenuViewport({
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
"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
className,
)}
{...props}
/>
</div>
)
);
}
function NavigationMenuLink({
@@ -130,11 +130,11 @@ function NavigationMenuLink({
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
className,
)}
{...props}
/>
)
);
}
function NavigationMenuIndicator({
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
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
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
);
}
export {
@@ -165,4 +165,4 @@ export {
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
};
+31 -31
View File
@@ -1,19 +1,19 @@
"use client"
"use client";
import {
CSSProperties,
ReactElement,
ReactNode,
type CSSProperties,
type ReactElement,
type ReactNode,
useEffect,
useRef,
useState,
} from "react"
} from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface NeonColorsProps {
firstColor: string
secondColor: string
firstColor: string;
secondColor: string;
}
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -23,14 +23,14 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
* @description
* The component to be rendered as the card
* */
as?: ReactElement
as?: ReactElement;
/**
* @default ""
* @type string
* @description
* The className of the card
*/
className?: string
className?: string;
/**
* @default ""
@@ -38,7 +38,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
* @description
* The children of the card
* */
children?: ReactNode
children?: ReactNode;
/**
* @default 5
@@ -46,7 +46,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
* @description
* The size of the border in pixels
* */
borderSize?: number
borderSize?: number;
/**
* @default 20
@@ -54,7 +54,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
* @description
* The size of the radius in pixels
* */
borderRadius?: number
borderRadius?: number;
/**
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
@@ -62,7 +62,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
* @description
* The colors of the neon gradient
* */
neonColors?: NeonColorsProps
neonColors?: NeonColorsProps;
}
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
@@ -76,31 +76,31 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
},
...props
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
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 })
const { offsetWidth, offsetHeight } = containerRef.current;
setDimensions({ width: offsetWidth, height: offsetHeight });
}
}
};
updateDimensions()
window.addEventListener("resize", updateDimensions)
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => {
window.removeEventListener("resize", updateDimensions)
}
}, [])
window.removeEventListener("resize", updateDimensions);
};
}, []);
useEffect(() => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
const { offsetWidth, offsetHeight } = containerRef.current;
setDimensions({ width: offsetWidth, height: offsetHeight });
}
}, [children])
}, []);
return (
<div
@@ -122,7 +122,7 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
}
className={cn(
"relative z-10 size-full rounded-[var(--border-radius)]",
className
className,
)}
{...props}
>
@@ -138,11 +138,11 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
"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"
"break-words",
)}
>
{children}
</div>
</div>
)
}
);
};
+22 -22
View File
@@ -1,16 +1,16 @@
"use client"
"use client";
import { ComponentPropsWithoutRef, useEffect, useRef } from "react"
import { useInView, useMotionValue, useSpring } from "motion/react"
import { useInView, useMotionValue, useSpring } from "motion/react";
import { type ComponentPropsWithoutRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
value: number
startValue?: number
direction?: "up" | "down"
delay?: number
decimalPlaces?: number
value: number;
startValue?: number;
direction?: "up" | "down";
delay?: number;
decimalPlaces?: number;
}
export function NumberTicker({
@@ -22,22 +22,22 @@ export function NumberTicker({
decimalPlaces = 0,
...props
}: NumberTickerProps) {
const ref = useRef<HTMLSpanElement>(null)
const motionValue = useMotionValue(direction === "down" ? value : startValue)
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" })
});
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.set(direction === "down" ? startValue : value);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [motionValue, isInView, delay, value, direction, startValue])
}, [motionValue, isInView, delay, value, direction, startValue]);
useEffect(
() =>
@@ -46,22 +46,22 @@ export function NumberTicker({
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)))
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces]
)
[springValue, decimalPlaces],
);
return (
<span
ref={ref}
className={cn(
"inline-block tracking-wider text-black tabular-nums dark:text-white",
className
className,
)}
{...props}
>
{startValue}
</span>
)
);
}
+147 -146
View File
@@ -1,81 +1,82 @@
"use client"
"use client";
import React, {
ComponentPropsWithoutRef,
import type React from "react";
import {
type ComponentPropsWithoutRef,
useEffect,
useRef,
useState,
} from "react"
} from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface MousePosition {
x: number
y: number
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 })
}
setMousePosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove)
}
}, [])
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
return mousePosition
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
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("#", "")
hex = hex.replace("#", "");
if (hex.length === 3) {
hex = hex
.split("")
.map((char) => char + char)
.join("")
.join("");
}
const hexInt = parseInt(hex, 16)
const red = (hexInt >> 16) & 255
const green = (hexInt >> 8) & 255
const blue = hexInt & 255
return [red, green, blue]
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
}
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 = "",
@@ -89,104 +90,104 @@ export const Particles: React.FC<ParticlesProps> = ({
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)
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")
context.current = canvasRef.current.getContext("2d");
}
initCanvas()
animate()
initCanvas();
animate();
const handleResize = () => {
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current)
clearTimeout(resizeTimeout.current);
}
resizeTimeout.current = setTimeout(() => {
initCanvas()
}, 200)
}
initCanvas();
}, 200);
};
window.addEventListener("resize", handleResize)
window.addEventListener("resize", handleResize);
return () => {
if (rafID.current != null) {
window.cancelAnimationFrame(rafID.current)
window.cancelAnimationFrame(rafID.current);
}
if (resizeTimeout.current) {
clearTimeout(resizeTimeout.current)
clearTimeout(resizeTimeout.current);
}
window.removeEventListener("resize", handleResize)
}
}, [color])
window.removeEventListener("resize", handleResize);
};
}, [animate, initCanvas]);
useEffect(() => {
onMouseMove()
}, [mousePosition.x, mousePosition.y])
onMouseMove();
}, [onMouseMove]);
useEffect(() => {
initCanvas()
}, [refresh])
initCanvas();
}, [initCanvas]);
const initCanvas = () => {
resizeCanvas()
drawParticles()
}
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
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
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
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)
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 = []
circles.current = [];
for (let i = 0; i < quantity; i++) {
const circle = circleParams()
drawCircle(circle)
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
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,
@@ -198,26 +199,26 @@ export const Particles: React.FC<ParticlesProps> = ({
dx,
dy,
magnetism,
}
}
};
};
const rgb = hexToRgb(color)
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)
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)
circles.current.push(circle);
}
}
}
};
const clearContext = () => {
if (context.current) {
@@ -225,34 +226,34 @@ export const Particles: React.FC<ParticlesProps> = ({
0,
0,
canvasSize.current.w,
canvasSize.current.h
)
canvasSize.current.h,
);
}
}
};
const drawParticles = () => {
clearContext()
const particleCount = quantity
clearContext();
const particleCount = quantity;
for (let i = 0; i < particleCount; i++) {
const circle = circleParams()
drawCircle(circle)
const circle = circleParams();
drawCircle(circle);
}
}
};
const remapValue = (
value: number,
start1: number,
end1: number,
start2: number,
end2: number
end2: number,
): number => {
const remapped =
((value - start1) * (end2 - start2)) / (end1 - start1) + start2
return remapped > 0 ? remapped : 0
}
((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
return remapped > 0 ? remapped : 0;
};
const animate = () => {
clearContext()
clearContext();
circles.current.forEach((circle: Circle, i: number) => {
// Handle the alpha value
const edge = [
@@ -260,29 +261,29 @@ export const Particles: React.FC<ParticlesProps> = ({
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 closestEdge = edge.reduce((a, b) => Math.min(a, b));
const remapClosestEdge = parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
)
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
);
if (remapClosestEdge > 1) {
circle.alpha += 0.02
circle.alpha += 0.02;
if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha
circle.alpha = circle.targetAlpha;
}
} else {
circle.alpha = circle.targetAlpha * remapClosestEdge
circle.alpha = circle.targetAlpha * remapClosestEdge;
}
circle.x += circle.dx + vx
circle.y += circle.dy + vy
circle.x += circle.dx + vx;
circle.y += circle.dy + vy;
circle.translateX +=
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
ease
ease;
circle.translateY +=
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
ease
ease;
drawCircle(circle, true)
drawCircle(circle, true);
// circle gets out of the canvas
if (
@@ -292,14 +293,14 @@ export const Particles: React.FC<ParticlesProps> = ({
circle.y > canvasSize.current.h + circle.size
) {
// remove the circle from the array
circles.current.splice(i, 1)
circles.current.splice(i, 1);
// create a new circle
const newCircle = circleParams()
drawCircle(newCircle)
const newCircle = circleParams();
drawCircle(newCircle);
}
})
rafID.current = window.requestAnimationFrame(animate)
}
});
rafID.current = window.requestAnimationFrame(animate);
};
return (
<div
@@ -310,5 +311,5 @@ export const Particles: React.FC<ParticlesProps> = ({
>
<canvas ref={canvasRef} className="size-full" />
</div>
)
}
);
};
+1 -1
View File
@@ -1,8 +1,8 @@
"use client";
import { Currency } from "@/types/currency";
import { formatPrice, formatPriceWithTc } from "@/lib/currency-utils";
import { cn } from "@/lib/utils";
import type { Currency } from "@/types/currency";
interface PriceDisplayProps {
value: number;
+29 -30
View File
@@ -1,16 +1,16 @@
"use client"
"use client";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
className,
)}
{...props}
>
@@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -40,14 +40,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -57,15 +57,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -78,7 +78,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
{...props}
@@ -88,7 +88,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -96,8 +96,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -108,8 +108,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
className,
)}
{...props}
>
@@ -131,8 +131,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -143,8 +143,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@@ -157,5 +157,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
};
+7 -7
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Separator({
className,
@@ -18,11 +18,11 @@ function Separator({
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
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+19 -24
View File
@@ -1,36 +1,31 @@
"use client"
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return (
<SheetPrimitive.Root
data-slot="sheet"
{...props}
/>
);
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -42,11 +37,11 @@ function SheetOverlay({
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
className,
)}
{...props}
/>
)
);
}
function SheetContent({
@@ -55,7 +50,7 @@ function SheetContent({
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
@@ -72,7 +67,7 @@ function SheetContent({
"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
className,
)}
{...props}
>
@@ -83,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -93,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -103,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -116,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -129,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -141,4 +136,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};
+21 -18
View File
@@ -1,15 +1,18 @@
import React, { ComponentPropsWithoutRef, CSSProperties } from "react"
import React, {
type ComponentPropsWithoutRef,
type CSSProperties,
} from "react";
import { cn } from "@/lib/utils"
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
shimmerColor?: string;
shimmerSize?: string;
borderRadius?: string;
shimmerDuration?: string;
background?: string;
className?: string;
children?: React.ReactNode;
}
export const ShimmerButton = React.forwardRef<
@@ -27,7 +30,7 @@ export const ShimmerButton = React.forwardRef<
children,
...props
},
ref
ref,
) => {
return (
<button
@@ -44,7 +47,7 @@ export const ShimmerButton = React.forwardRef<
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
className,
)}
ref={ref}
{...props}
@@ -53,7 +56,7 @@ export const ShimmerButton = React.forwardRef<
<div
className={cn(
"-z-30 blur-[2px]",
"[container-type:size] absolute inset-0 overflow-visible"
"[container-type:size] absolute inset-0 overflow-visible",
)}
>
{/* spark */}
@@ -78,19 +81,19 @@ export const ShimmerButton = React.forwardRef<
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
// on click
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]"
"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)]"
"absolute [inset:var(--cut)] -z-20 [border-radius:var(--radius)] [background:var(--bg)]",
)}
/>
</button>
)
}
)
);
},
);
ShimmerButton.displayName = "ShimmerButton"
ShimmerButton.displayName = "ShimmerButton";
+8 -8
View File
@@ -1,25 +1,25 @@
"use client"
"use client";
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Width of the border in pixels
* @default 1
*/
borderWidth?: number
borderWidth?: number;
/**
* Duration of the animation in seconds
* @default 14
*/
duration?: number
duration?: number;
/**
* Color of the border, can be a single color or an array of colors
* @default "#000000"
*/
shineColor?: string | string[]
shineColor?: string | string[];
}
/**
@@ -55,9 +55,9 @@ export function ShineBorder({
}
className={cn(
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
className
className,
)}
{...props}
/>
)
);
}
-5
View File
@@ -13,8 +13,3 @@ function Skeleton({
}
export { Skeleton };
+11 -11
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Tabs({
className,
@@ -15,7 +15,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function TabsList({
@@ -27,11 +27,11 @@ function TabsList({
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
className,
)}
{...props}
/>
)
);
}
function TabsTrigger({
@@ -43,11 +43,11 @@ function TabsTrigger({
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
className,
)}
{...props}
/>
)
);
}
function TabsContent({
@@ -60,7 +60,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+2 -2
View File
@@ -10,13 +10,13 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
ref={ref}
{...props}
/>
);
}
},
);
Textarea.displayName = "Textarea";
+14 -14
View File
@@ -1,15 +1,15 @@
"use client"
"use client";
import { useEffect, useState } from "react"
import { AnimatePresence, motion, MotionProps } from "motion/react"
import { AnimatePresence, type MotionProps, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
interface WordRotateProps {
words: string[]
duration?: number
motionProps?: MotionProps
className?: string
words: string[];
duration?: number;
motionProps?: MotionProps;
className?: string;
}
export function WordRotate({
@@ -23,16 +23,16 @@ export function WordRotate({
},
className,
}: WordRotateProps) {
const [index, setIndex] = useState(0)
const [index, setIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setIndex((prevIndex) => (prevIndex + 1) % words.length)
}, duration)
setIndex((prevIndex) => (prevIndex + 1) % words.length);
}, duration);
// Clean up interval on unmount
return () => clearInterval(interval)
}, [words, duration])
return () => clearInterval(interval);
}, [words, duration]);
return (
<div className="overflow-hidden py-2">
@@ -46,5 +46,5 @@ export function WordRotate({
</motion.h1>
</AnimatePresence>
</div>
)
);
}
+3 -3
View File
@@ -1,9 +1,9 @@
"use client";
import { Construction, Home, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Construction, Home } from "lucide-react";
import Link from "next/link";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
import { DotPattern } from "@/components/ui/dot-pattern";
import { cn } from "@/lib/utils";
@@ -23,7 +23,7 @@ export function UnderConstruction({
{/* Background Pattern */}
<DotPattern
className={cn(
"absolute inset-0 h-full w-full text-muted-foreground/20"
"absolute inset-0 h-full w-full text-muted-foreground/20",
)}
/>