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

+3 -4
View File
@@ -1,12 +1,12 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import Header from "@/components/Header";
import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePage";
import { getSession } from "@/lib/redis/session";
import { getUserById } from "@/lib/prisma/queries/users";
import Header from "@/components/Header";
import { searchAdminItems } from "@/lib/prisma/queries/items";
import { getActiveServers } from "@/lib/prisma/queries/servers";
import { getUserById } from "@/lib/prisma/queries/users";
import { getSession } from "@/lib/redis/session";
export async function generateMetadata(): Promise<Metadata> {
return {
@@ -57,4 +57,3 @@ export default async function AdminNewItemPage({
</div>
);
}
+24 -11
View File
@@ -1,12 +1,12 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs";
import Header from "@/components/Header";
import { getSession } from "@/lib/redis/session";
import { searchAdminItems } from "@/lib/prisma/queries/items";
import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers";
import { getUserById } from "@/lib/prisma/queries/users";
import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs";
import { searchAdminItems } from "@/lib/prisma/queries/items";
import { getSession } from "@/lib/redis/session";
export async function generateMetadata(): Promise<Metadata> {
return {
@@ -30,7 +30,8 @@ export default async function AdminPage({
searchParams instanceof Promise ? await searchParams : searchParams;
const tabParam = resolvedSearchParams?.tab;
const initialTab =
typeof tabParam === "string" && ["overview", "subscriptions", "items"].includes(tabParam)
typeof tabParam === "string" &&
["overview", "subscriptions", "items"].includes(tabParam)
? (tabParam as "overview" | "subscriptions" | "items")
: "overview";
@@ -58,14 +59,24 @@ export default async function AdminPage({
...tier,
price_monthly: tier.price_monthly ? Number(tier.price_monthly) : null,
price_yearly: tier.price_yearly ? Number(tier.price_yearly) : null,
created_at: tier.created_at instanceof Date ? tier.created_at.toISOString() : tier.created_at,
updated_at: tier.updated_at instanceof Date ? tier.updated_at.toISOString() : tier.updated_at,
created_at:
tier.created_at instanceof Date
? tier.created_at.toISOString()
: tier.created_at,
updated_at:
tier.updated_at instanceof Date
? tier.updated_at.toISOString()
: tier.updated_at,
features: featureList.map((feature) => ({
...feature,
created_at:
feature.created_at instanceof Date ? feature.created_at.toISOString() : feature.created_at ?? null,
feature.created_at instanceof Date
? feature.created_at.toISOString()
: (feature.created_at ?? null),
updated_at:
feature.updated_at instanceof Date ? feature.updated_at.toISOString() : feature.updated_at ?? null,
feature.updated_at instanceof Date
? feature.updated_at.toISOString()
: (feature.updated_at ?? null),
})),
};
});
@@ -75,9 +86,12 @@ export default async function AdminPage({
<Header />
<main className="container mx-auto px-4 py-10 space-y-8">
<section className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Painel administrativo</h1>
<h1 className="text-3xl font-bold tracking-tight">
Painel administrativo
</h1>
<p className="text-muted-foreground max-w-2xl">
Centralize a gestão de planos, permissões e o catálogo de itens. Utilize as abas para acessar cada área temática.
Centralize a gestão de planos, permissões e o catálogo de itens.
Utilize as abas para acessar cada área temática.
</p>
</section>
@@ -92,4 +106,3 @@ export default async function AdminPage({
</div>
);
}
+1 -3
View File
@@ -4,8 +4,7 @@ import { redirect } from "next/navigation";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "Assinaturas e Permissões",
description:
"Redirecionando para o painel administrativo centralizado.",
description: "Redirecionando para o painel administrativo centralizado.",
};
}
@@ -17,4 +16,3 @@ export default async function AdminSubscriptionsRedirect({
const { locale } = await params;
redirect(`/${locale}/admin?tab=subscriptions`);
}
+6 -6
View File
@@ -1,13 +1,13 @@
"use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import confetti from "canvas-confetti";
import Header from "@/components/Header";
import { Button } from "@/components/ui/button";
import { BlurFade } from "@/components/ui/blur-fade";
import { Heart, Share2, Home } from "lucide-react";
import { Heart, Home, Share2 } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Header from "@/components/Header";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
export default function ThankYouPage() {
const params = useParams();
+10 -11
View File
@@ -1,20 +1,19 @@
import Header from "@/components/Header";
import { Heart } from "lucide-react";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { DonationCard } from "@/components/donation-card";
import { BlurFade } from "@/components/ui/blur-fade";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Header from "@/components/Header";
import { ShareButton } from "@/components/share-button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Heart } from "lucide-react";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ShareButton } from "@/components/share-button";
import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { BlurFade } from "@/components/ui/blur-fade";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
export async function generateMetadata({
params,
@@ -60,7 +59,7 @@ export default async function SupportPage({
};
// Custos mensais REAIS
const monthlyCosts = {
const _monthlyCosts = {
vps: 69, // R$ 69/mês
devHours: 40, // média horas/mês
devRate: 50, // R$ 50/hora
+13 -4
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import Script from "next/script";
import { ThemeProvider } from "@/components/theme-provider";
import { QueryProvider } from "@/lib/providers/query-provider";
@@ -113,14 +113,19 @@ export default async function LocaleLayout({
<QueryProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark">
<Script id="structured-data-organization" type="application/ld+json" strategy="afterInteractive">
<Script
id="structured-data-organization"
type="application/ld+json"
strategy="afterInteractive"
>
{JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "Tibia Trade",
url: baseUrl,
logo: `${baseUrl}/logo.png`,
description: "Marketplace multi-servidor seguro para negociação de itens de Tibia",
description:
"Marketplace multi-servidor seguro para negociação de itens de Tibia",
sameAs: ["https://discord.gg/tibiatrade"],
contactPoint: {
"@type": "ContactPoint",
@@ -129,7 +134,11 @@ export default async function LocaleLayout({
},
})}
</Script>
<Script id="structured-data-website" type="application/ld+json" strategy="afterInteractive">
<Script
id="structured-data-website"
type="application/ld+json"
strategy="afterInteractive"
>
{JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
+88 -51
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { MarketplaceFilters } from "@/components/marketplace-filters";
import { ItemCard } from "@/components/item-card";
import { MarketplaceFilters } from "@/components/marketplace-filters";
interface Item {
id: string | number;
@@ -38,7 +38,9 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
const [categories, setCategories] = useState<string[]>([]);
const [rarities, setRarities] = useState<string[]>([]);
const [sort, setSort] = useState<string>("name");
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[] | undefined>(undefined);
const [categoryOptions, setCategoryOptions] = useState<
{ value: string; label: string }[] | undefined
>(undefined);
const [vocation, setVocation] = useState<string | null>(null);
const debounceRef = useRef<number | undefined>(undefined);
@@ -77,7 +79,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.categories)) {
const opts = (data.categories as string[]).map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) }));
const opts = (data.categories as string[]).map((c) => ({
value: c,
label: c.charAt(0).toUpperCase() + c.slice(1),
}));
setCategoryOptions(opts);
}
} catch {
@@ -90,7 +95,12 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
setPage(1);
setQuery(q);
};
const onFilter = (f: { rarity: string[]; category: string[]; priceRange?: { min: number; max: number }; vocation?: string | null }) => {
const onFilter = (f: {
rarity: string[];
category: string[];
priceRange?: { min: number; max: number };
vocation?: string | null;
}) => {
setPage(1);
setCategories(f.category || []);
setRarities(f.rarity || []);
@@ -103,7 +113,14 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return (
<div className="space-y-8">
<MarketplaceFilters onSearch={onSearch} onFilter={onFilter} onSort={onSort} totalItems={total} showRarity={false} categoryOptionsOverride={categoryOptions} />
<MarketplaceFilters
onSearch={onSearch}
onFilter={onFilter}
onSort={onSort}
totalItems={total}
showRarity={false}
categoryOptionsOverride={categoryOptions}
/>
<section className="grid gap-6 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 grid-flow-row-dense">
{items.map((item) => {
@@ -142,7 +159,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return map[v] || v || "other";
};
const categoryPt = typeof anyItem.categoria === "string" ? anyItem.categoria : undefined;
const categoryPt =
typeof anyItem.categoria === "string"
? anyItem.categoria
: undefined;
const category = normalizeCategory(item.category ?? categoryPt);
const name = item.name ?? anyItem.nome ?? String(item.id);
const image = item.image ?? anyItem.image_url ?? "";
@@ -152,39 +172,39 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
typeof item.level === "number"
? item.level
: typeof anyItem.nivel === "number"
? anyItem.nivel
: typeof anyItem.lvl === "number"
? anyItem.lvl
: undefined;
? anyItem.nivel
: typeof anyItem.lvl === "number"
? anyItem.lvl
: undefined;
const weight: number | undefined =
typeof item.weight === "number"
? item.weight
: typeof anyItem.peso === "number"
? anyItem.peso
: typeof anyItem.peso_em_oz === "number"
? anyItem.peso_em_oz
: undefined;
? anyItem.peso
: typeof anyItem.peso_em_oz === "number"
? anyItem.peso_em_oz
: undefined;
const slots: number | undefined =
typeof item.slots === "number"
? item.slots
: typeof anyItem.slots === "number"
? anyItem.slots
: undefined;
? anyItem.slots
: undefined;
const attack: number | undefined =
typeof anyItem.ataque === "number"
? anyItem.ataque
: typeof anyItem.atq === "number"
? anyItem.atq
: undefined;
? anyItem.atq
: undefined;
const defense: number | undefined =
typeof anyItem.defesa === "number"
? anyItem.defesa
: typeof anyItem.def === "number"
? anyItem.def
: undefined;
? anyItem.def
: undefined;
const armor: number | undefined =
typeof anyItem.arm === "number" ? anyItem.arm : undefined;
@@ -192,9 +212,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
const skill: string | undefined =
typeof item.skill === "string" && item.skill !== "Nenhum."
? item.skill
: typeof anyItem.skill_para_usar === "string" && anyItem.skill_para_usar !== "Nenhum."
? anyItem.skill_para_usar
: undefined;
: typeof anyItem.skill_para_usar === "string" &&
anyItem.skill_para_usar !== "Nenhum."
? anyItem.skill_para_usar
: undefined;
const vocation: string | undefined =
typeof anyItem.voc === "string" && anyItem.voc
? anyItem.voc
@@ -203,31 +224,45 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return (
<ItemCard
key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`}
item={{
id: String(item.id),
name,
image,
category: category as any,
rarity: (item.rarity as any) ?? "common",
categoryPt,
stats: {
weight,
slots,
attack,
defense,
armor,
} as any,
requirements: {
level,
skill,
vocation,
} as any,
// Campos específicos de aljavas (e reutilizáveis para outras categorias)
volume: typeof anyItem.volume === "number" ? anyItem.volume : undefined,
bonus_de_skill: typeof anyItem.bonus_de_skill === "number" ? anyItem.bonus_de_skill : undefined,
protecao: typeof anyItem.protecao === "string" ? anyItem.protecao : undefined,
dropa_de: typeof anyItem.dropa_de === "string" ? anyItem.dropa_de : undefined,
} as any}
item={
{
id: String(item.id),
name,
image,
category: category as any,
rarity: (item.rarity as any) ?? "common",
categoryPt,
stats: {
weight,
slots,
attack,
defense,
armor,
} as any,
requirements: {
level,
skill,
vocation,
} as any,
// Campos específicos de aljavas (e reutilizáveis para outras categorias)
volume:
typeof anyItem.volume === "number"
? anyItem.volume
: undefined,
bonus_de_skill:
typeof anyItem.bonus_de_skill === "number"
? anyItem.bonus_de_skill
: undefined,
protecao:
typeof anyItem.protecao === "string"
? anyItem.protecao
: undefined,
dropa_de:
typeof anyItem.dropa_de === "string"
? anyItem.dropa_de
: undefined,
} as any
}
mode="catalog"
/>
);
@@ -236,14 +271,18 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
<div className="flex items-center justify-center gap-2">
<button
type="button"
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Anterior
</button>
<span className="text-sm text-muted-foreground">Página {page} de {totalPages}</span>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<button
type="button"
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
@@ -254,5 +293,3 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
</div>
);
}
+17 -9
View File
@@ -1,5 +1,5 @@
import Header from "@/components/Header";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import LibraryClient from "./LibraryClient";
interface Item {
@@ -18,17 +18,24 @@ interface Item {
async function fetchItems(params: URLSearchParams) {
const qs = params.toString();
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`, {
// Em ambiente server, revalidação curta para manter leve
next: { revalidate: 15 },
});
const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`,
{
// Em ambiente server, revalidação curta para manter leve
next: { revalidate: 15 },
},
);
if (!res.ok) {
return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 };
}
return res.json();
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" });
return {
@@ -37,7 +44,10 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
};
}
export default async function LibraryPage({ params, searchParams }: {
export default async function LibraryPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
@@ -69,5 +79,3 @@ export default async function LibraryPage({ params, searchParams }: {
</div>
);
}
+238 -222
View File
@@ -1,8 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Grid, List, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useLocale } from "next-intl";
import { useEffect, useState } from "react";
import { AdCard } from "@/components/marketplace/AdCard";
import { CreateAdModal } from "@/components/marketplace/CreateAdModal";
import { MyAdsPanel } from "@/components/marketplace/MyAdsPanel";
import { MyPurchaseIntentionsPanel } from "@/components/marketplace/MyPurchaseIntentionsPanel";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,19 +17,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Search, SortAsc, SortDesc, Filter, X, Grid, List } from "lucide-react";
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
import { AdCard } from "@/components/marketplace/AdCard";
import { CreateAdModal } from "@/components/marketplace/CreateAdModal";
import { MyAdsPanel } from "@/components/marketplace/MyAdsPanel";
import { MyPurchaseIntentionsPanel } from "@/components/marketplace/MyPurchaseIntentionsPanel";
import { useSession } from "@/lib/hooks/use-session";
import { useListings, useInvalidateListings } from "@/lib/hooks/use-listings";
import { useInvalidateListings, useListings } from "@/lib/hooks/use-listings";
import { useMyPurchaseIntentions } from "@/lib/hooks/use-purchase-intentions";
import { useLocale } from "next-intl";
import { useSession } from "@/lib/hooks/use-session";
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
interface Listing {
listing_id: number;
@@ -57,7 +57,7 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
const { trackSearch, trackFilter, trackPageView } = useMarketTelemetry();
const invalidateListings = useInvalidateListings();
const locale = useLocale();
const [page, setPage] = useState<number>(initial.page || 1);
const [pageSize] = useState<number>(initial.pageSize || 20);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -73,7 +73,12 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
const [activeTab, setActiveTab] = useState<string>("explore");
// Usar React Query para buscar listings com atualização automática
const { listings, total, totalPages, isLoading: loading } = useListings({
const {
listings,
total,
totalPages,
isLoading: loading,
} = useListings({
search: searchQuery || undefined,
server_name: serverName || undefined,
price_min: priceMin || undefined,
@@ -144,7 +149,7 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
<div>
<h1 className="text-3xl font-bold">Marketplace</h1>
<p className="text-muted-foreground mt-1">
{activeTab === "explore"
{activeTab === "explore"
? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis`
: "Gerencie seus anúncios"}
</p>
@@ -161,226 +166,238 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
<TabsList>
<TabsTrigger value="explore">Explorar</TabsTrigger>
{user && <TabsTrigger value="my-ads">Meus Anúncios</TabsTrigger>}
{user && (
<MyPurchaseIntentionsTabTrigger />
)}
{user && <MyPurchaseIntentionsTabTrigger />}
</TabsList>
{/* Aba: Explorar Anúncios */}
<TabsContent value="explore" className="space-y-6 mt-6">
{/* Barra de busca */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar anúncios por item ou descrição..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 pr-4"
/>
</div>
{/* Filtros e controles */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por servidor */}
<Input
placeholder="Filtrar por servidor"
value={serverName}
onChange={(e) => {
setServerName(e.target.value);
handleFilter({ server: e.target.value });
}}
className="w-48"
/>
{/* Filtro por preço mínimo */}
<Input
type="number"
placeholder="Preço min"
value={priceMin}
onChange={(e) => {
setPriceMin(e.target.value);
handleFilter({ priceMin: e.target.value ? parseFloat(e.target.value) : undefined });
}}
className="w-32"
/>
{/* Filtro por preço máximo */}
<Input
type="number"
placeholder="Preço max"
value={priceMax}
onChange={(e) => {
setPriceMax(e.target.value);
handleFilter({ priceMax: e.target.value ? parseFloat(e.target.value) : undefined });
}}
className="w-32"
/>
{/* Ordenação */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{(() => {
const selectedOption = sortOptions.find((opt) => opt.value === sortBy);
const Icon = selectedOption?.icon;
return Icon ? <Icon className="h-4 w-4" /> : null;
})()}
{sortOptions.find((opt) => opt.value === sortBy)?.label || "Ordenar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Ordenar por</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onClick={() => handleSort(option.value)}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
{sortBy === option.value && (
<div className="w-2 h-2 bg-primary rounded-full ml-auto" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Toggle de visualização */}
<div className="flex items-center gap-1 border rounded-md p-1">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Limpar filtros */}
{activeFiltersCount > 0 && (
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Limpar Filtros
</Button>
)}
</div>
{/* Badges de filtros ativos */}
{activeFiltersCount > 0 && (
<div className="flex flex-wrap gap-2">
{searchQuery && (
<Badge variant="secondary" className="gap-1">
Busca: {searchQuery}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setSearchQuery("");
setPage(1);
}}
/>
</Badge>
)}
{serverName && (
<Badge variant="secondary" className="gap-1">
Servidor: {serverName}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setServerName("");
setPage(1);
}}
/>
</Badge>
)}
{(priceMin || priceMax) && (
<Badge variant="secondary" className="gap-1">
Preço: {priceMin || "0"} - {priceMax || "∞"}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setPriceMin("");
setPriceMax("");
setPage(1);
}}
/>
</Badge>
)}
</div>
)}
{/* Listagem de anúncios */}
{loading ? (
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Nenhum anúncio encontrado
</p>
<p className="text-muted-foreground text-sm mt-2">
Tente ajustar os filtros ou criar um novo anúncio
</p>
</div>
) : (
<>
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{listings.map((listing) => (
<AdCard key={listing.listing_id} listing={listing} viewMode={viewMode} />
))}
{/* Barra de busca */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar anúncios por item ou descrição..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10 pr-4"
/>
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
{/* Filtros e controles */}
<div className="flex flex-wrap items-center gap-4">
{/* Filtro por servidor */}
<Input
placeholder="Filtrar por servidor"
value={serverName}
onChange={(e) => {
setServerName(e.target.value);
handleFilter({ server: e.target.value });
}}
className="w-48"
/>
{/* Filtro por preço mínimo */}
<Input
type="number"
placeholder="Preço min"
value={priceMin}
onChange={(e) => {
setPriceMin(e.target.value);
handleFilter({
priceMin: e.target.value
? parseFloat(e.target.value)
: undefined,
});
}}
className="w-32"
/>
{/* Filtro por preço máximo */}
<Input
type="number"
placeholder="Preço max"
value={priceMax}
onChange={(e) => {
setPriceMax(e.target.value);
handleFilter({
priceMax: e.target.value
? parseFloat(e.target.value)
: undefined,
});
}}
className="w-32"
/>
{/* Ordenação */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{(() => {
const selectedOption = sortOptions.find(
(opt) => opt.value === sortBy,
);
const Icon = selectedOption?.icon;
return Icon ? <Icon className="h-4 w-4" /> : null;
})()}
{sortOptions.find((opt) => opt.value === sortBy)?.label ||
"Ordenar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Ordenar por</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onClick={() => handleSort(option.value)}
className="flex items-center gap-2"
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
{sortBy === option.value && (
<div className="w-2 h-2 bg-primary rounded-full ml-auto" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Toggle de visualização */}
<div className="flex items-center gap-1 border rounded-md p-1">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
>
Anterior
<Grid className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button
variant="outline"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
>
Próxima
<List className="h-4 w-4" />
</Button>
</div>
{/* Limpar filtros */}
{activeFiltersCount > 0 && (
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Limpar Filtros
</Button>
)}
</div>
{/* Badges de filtros ativos */}
{activeFiltersCount > 0 && (
<div className="flex flex-wrap gap-2">
{searchQuery && (
<Badge variant="secondary" className="gap-1">
Busca: {searchQuery}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setSearchQuery("");
setPage(1);
}}
/>
</Badge>
)}
{serverName && (
<Badge variant="secondary" className="gap-1">
Servidor: {serverName}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setServerName("");
setPage(1);
}}
/>
</Badge>
)}
{(priceMin || priceMax) && (
<Badge variant="secondary" className="gap-1">
Preço: {priceMin || "0"} - {priceMax || "∞"}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setPriceMin("");
setPriceMax("");
setPage(1);
}}
/>
</Badge>
)}
</div>
)}
{/* Listagem de anúncios */}
{loading ? (
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full" />
))}
</div>
) : listings.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">
Nenhum anúncio encontrado
</p>
<p className="text-muted-foreground text-sm mt-2">
Tente ajustar os filtros ou criar um novo anúncio
</p>
</div>
) : (
<>
<div
className={
viewMode === "grid"
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
: "space-y-4"
}
>
{listings.map((listing) => (
<AdCard
key={listing.listing_id}
listing={listing}
viewMode={viewMode}
/>
))}
</div>
{/* Paginação */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Anterior
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button
variant="outline"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Próxima
</Button>
</div>
)}
</>
)}
</>
)}
{/* Modal de criação de anúncio */}
{showCreateModal && (
@@ -437,4 +454,3 @@ function MyPurchaseIntentionsTabTrigger() {
</TabsTrigger>
);
}
+1 -5
View File
@@ -1,9 +1,6 @@
import Header from "@/components/Header";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import Header from "@/components/Header";
import { AnalyticsDashboard } from "@/components/marketplace/AnalyticsDashboard";
import { requireAuth } from "@/lib/auth/middleware";
import { NextRequest } from "next/server";
export async function generateMetadata({
params,
@@ -38,4 +35,3 @@ export default async function AnalyticsPage({
</div>
);
}
+29 -21
View File
@@ -1,8 +1,7 @@
import Header from "@/components/Header";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import MarketClient from "./MarketClient";
import Header from "@/components/Header";
import { getActiveListings } from "@/lib/prisma/queries/listings";
import MarketClient from "./MarketClient";
export async function generateMetadata({
params,
@@ -46,12 +45,14 @@ export default async function MarketPage({
const sp = (await searchParams) ?? {};
const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1;
const pageSize = typeof sp.pageSize === "string" ? parseInt(sp.pageSize, 10) : 20;
const pageSize =
typeof sp.pageSize === "string" ? parseInt(sp.pageSize, 10) : 20;
// Buscar anúncios ativos
const listingsData = await getActiveListings({
search: typeof sp.search === "string" ? sp.search : undefined,
server_name: typeof sp.server_name === "string" ? sp.server_name : undefined,
server_name:
typeof sp.server_name === "string" ? sp.server_name : undefined,
page,
pageSize,
sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as
@@ -60,27 +61,34 @@ export default async function MarketPage({
| "price_tibia_coins"
| "price_gold_coins"
| "views_count",
sortOrder: (typeof sp.sortOrder === "string" ? sp.sortOrder : "DESC") as "ASC" | "DESC",
sortOrder: (typeof sp.sortOrder === "string" ? sp.sortOrder : "DESC") as
| "ASC"
| "DESC",
});
// Converter Date para string para compatibilidade com o tipo do cliente
const serializedListings = listingsData.listings.map((listing) => ({
...listing,
created_at: listing.created_at instanceof Date
? listing.created_at.toISOString()
: listing.created_at,
updated_at: listing.updated_at instanceof Date
? listing.updated_at.toISOString()
: listing.updated_at,
sold_at: listing.sold_at instanceof Date
? listing.sold_at.toISOString()
: listing.sold_at,
auction_ends_at: listing.auction_ends_at instanceof Date
? listing.auction_ends_at.toISOString()
: listing.auction_ends_at,
expires_at: listing.expires_at instanceof Date
? listing.expires_at.toISOString()
: listing.expires_at,
created_at:
listing.created_at instanceof Date
? listing.created_at.toISOString()
: listing.created_at,
updated_at:
listing.updated_at instanceof Date
? listing.updated_at.toISOString()
: listing.updated_at,
sold_at:
listing.sold_at instanceof Date
? listing.sold_at.toISOString()
: listing.sold_at,
auction_ends_at:
listing.auction_ends_at instanceof Date
? listing.auction_ends_at.toISOString()
: listing.auction_ends_at,
expires_at:
listing.expires_at instanceof Date
? listing.expires_at.toISOString()
: listing.expires_at,
}));
return (
+3 -3
View File
@@ -1,19 +1,19 @@
import { DonationFooterCard } from "@/components/donation-footer-card";
import Header from "@/components/Header";
import { EscrowBanner } from "@/components/sections/escrow-banner";
import { EscrowExplanation } from "@/components/sections/escrow-explanation";
import { FeaturedItems } from "@/components/sections/featured-items";
import { HeroSection } from "@/components/sections/hero-section";
import { HowItWorks } from "@/components/sections/how-it-works";
import { FeaturedItems } from "@/components/sections/featured-items";
import { MarketExample } from "@/components/sections/market-example";
import { MarketStats } from "@/components/sections/market-stats";
import { DonationFooterCard } from "@/components/donation-footer-card";
export default function Home() {
return (
<div className="min-h-screen">
<Header />
<EscrowBanner />
<main role="main" aria-label="Página principal do Tibia Trade">
<main aria-label="Página principal do Tibia Trade">
<HeroSection />
<HowItWorks />
<FeaturedItems />
+3 -3
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({
params,
@@ -9,7 +9,7 @@ export async function generateMetadata({
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" });
const _t = await getTranslations({ locale, namespace: "underConstruction" });
const titles = {
pt: "Preços - Estrutura de Custos",
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({
params,
@@ -1,10 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import { addColumnToCategory, type SQLiteColumnDefinition } from "@/lib/items/sqlite-manager";
import {
addColumnToCategory,
type SQLiteColumnDefinition,
} from "@/lib/items/sqlite-manager";
import { secureLogger } from "@/lib/secure-logger";
function sanitizeColumn(column: any): SQLiteColumnDefinition {
const name = typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
const name =
typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
const type = typeof column?.type === "string" ? column.type : "TEXT";
const notNull = column?.notNull === true;
const defaultValue = column?.defaultValue ?? undefined;
@@ -26,7 +30,10 @@ function sanitizeColumn(column: any): SQLiteColumnDefinition {
} satisfies SQLiteColumnDefinition;
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
@@ -38,14 +45,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const body = await request.json();
const column = sanitizeColumn(body?.column ?? body);
const created = addColumnToCategory({ category: name, column });
return NextResponse.json({ success: true, column: created }, { status: 201 });
return NextResponse.json(
{ success: true, column: created },
{ status: 201 },
);
} catch (error) {
secureLogger.error("Falha ao adicionar coluna à categoria", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Erro ao adicionar coluna" },
{
success: false,
error:
error instanceof Error ? error.message : "Erro ao adicionar coluna",
},
{ status: 400 },
);
}
}
+28 -12
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
listCategoryTemplates,
createCategoryTemplate,
type SQLiteColumnDefinition,
type CreateCategoryInput,
createCategoryTemplate,
listCategoryTemplates,
type SQLiteColumnDefinition,
} from "@/lib/items/sqlite-manager";
import { secureLogger } from "@/lib/secure-logger";
@@ -16,7 +16,10 @@ export async function GET(request: NextRequest) {
try {
const templates = listCategoryTemplates();
return NextResponse.json({ success: true, categories: templates }, { status: 200 });
return NextResponse.json(
{ success: true, categories: templates },
{ status: 200 },
);
} catch (error) {
secureLogger.error("Falha ao listar categorias dinâmicas", error);
return NextResponse.json(
@@ -27,7 +30,8 @@ export async function GET(request: NextRequest) {
}
function sanitizeColumn(column: any): SQLiteColumnDefinition {
const name = typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
const name =
typeof column?.name === "string" ? column.name.trim().toLowerCase() : "";
const type = typeof column?.type === "string" ? column.type : "TEXT";
const notNull = column?.notNull === true;
const defaultValue = column?.defaultValue ?? undefined;
@@ -53,7 +57,8 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const rawName = typeof body?.name === "string" ? body.name.trim().toLowerCase() : "";
const rawName =
typeof body?.name === "string" ? body.name.trim().toLowerCase() : "";
if (!rawName) {
return NextResponse.json(
{ success: false, error: "Nome da categoria é obrigatório" },
@@ -61,10 +66,17 @@ export async function POST(request: NextRequest) {
);
}
const columnsArray: unknown[] = Array.isArray(body?.columns) ? body.columns : [];
const columns = columnsArray.map((column) => sanitizeColumn(column)).filter((column) => {
return !(column && ["id", "nome", "image_url", "image_local_path"].includes(column.name));
});
const columnsArray: unknown[] = Array.isArray(body?.columns)
? body.columns
: [];
const columns = columnsArray
.map((column) => sanitizeColumn(column))
.filter((column) => {
return !(
column &&
["id", "nome", "image_url", "image_local_path"].includes(column.name)
);
});
const input: CreateCategoryInput = {
name: rawName,
@@ -76,7 +88,11 @@ export async function POST(request: NextRequest) {
} catch (error) {
secureLogger.error("Falha ao criar categoria dinâmica", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Erro ao criar categoria" },
{
success: false,
error:
error instanceof Error ? error.message : "Erro ao criar categoria",
},
{ status: 400 },
);
}
+6 -6
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import { type NextRequest, NextResponse } from "next/server";
import { generateItemStructure } from "@/lib/ai/generate-item-structure";
import { calculateTokenCost } from "@/lib/ai/token-pricing";
import { recordTokenUsage } from "@/lib/ai/token-usage";
import { requireAdminUser } from "@/lib/auth/middleware";
export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request);
@@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Forneça descrição ou ao menos uma imagem para análise.",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -64,7 +64,8 @@ export async function POST(request: NextRequest) {
});
// Cálculo de custo baseado em usageMetadata
const model = (result.model ?? "gemini-2.5-flash") as keyof typeof import("@/lib/ai/token-pricing").TOKEN_PRICING;
const _model = (result.model ??
"gemini-2.5-flash") as keyof typeof import("@/lib/ai/token-pricing").TOKEN_PRICING;
const breakdown = calculateTokenCost("gemini-2.5-flash", result.usage);
// Persistir histórico para relatórios (não bloqueia resposta; erros são logados no Redis util)
@@ -104,7 +105,6 @@ export async function POST(request: NextRequest) {
model: result.model ?? "gemini-2.5-flash",
requestId,
},
{ status: 200 }
{ status: 200 },
);
}
+91 -41
View File
@@ -1,18 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
insertCategoryItem,
listCategoryTemplates,
updateCategoryItem,
} from "@/lib/items/sqlite-manager";
import {
createAdminItem,
getItemById,
searchAdminItems,
updateAdminItem,
getItemById,
} from "@/lib/prisma/queries/items";
import {
insertCategoryItem,
updateCategoryItem,
listCategoryTemplates,
} from "@/lib/items/sqlite-manager";
function parseNumber(value: string | null | undefined, fallback: number): number {
function parseNumber(
value: string | null | undefined,
fallback: number,
): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
@@ -32,18 +35,25 @@ function parseList(value: unknown): string[] {
return [];
}
function normalizeDynamicFields(category: string, rawFields: Record<string, unknown> | undefined) {
function normalizeDynamicFields(
category: string,
rawFields: Record<string, unknown> | undefined,
) {
if (!rawFields || typeof rawFields !== "object") {
return {} as Record<string, unknown>;
}
const templates = listCategoryTemplates();
const template = templates.find((entry) => entry.name === category.toLowerCase());
const template = templates.find(
(entry) => entry.name === category.toLowerCase(),
);
if (!template) {
return rawFields;
}
const columnMap = new Map(template.columns.map((column) => [column.name, column]));
const columnMap = new Map(
template.columns.map((column) => [column.name, column]),
);
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawFields)) {
@@ -114,7 +124,7 @@ export async function GET(request: NextRequest) {
error: "Falha ao consultar itens",
message: error?.message || "Erro desconhecido",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -128,21 +138,28 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const name = typeof body?.name === "string" ? body.name.trim() : "";
const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : "";
const originServer = typeof body?.server === "string" ? body.server.trim() || null : null;
const category =
typeof body?.category === "string"
? body.category.trim().toLowerCase()
: "";
const originServer =
typeof body?.server === "string" ? body.server.trim() || null : null;
if (!name || !category) {
return NextResponse.json(
{ success: false, error: "Nome e categoria são obrigatórios." },
{ status: 400 }
{ status: 400 },
);
}
const dynamicFields = normalizeDynamicFields(category, body?.fields);
const imageUrl = typeof body?.imageUrl === "string" ? body.imageUrl.trim() || null : null;
const imageUrl =
typeof body?.imageUrl === "string" ? body.imageUrl.trim() || null : null;
const imageLocalPath =
typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() || null : null;
typeof body?.imageLocalPath === "string"
? body.imageLocalPath.trim() || null
: null;
const sqliteData = {
nome: name,
@@ -158,16 +175,25 @@ export async function POST(request: NextRequest) {
const sqliteId = insertCategoryItem({ category, data: sqliteData });
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {};
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>;
const metadataBase =
typeof body?.metadata === "object" && body.metadata !== null
? body.metadata
: {};
const metadata = { ...metadataBase, ...dynamicFields } as Record<
string,
unknown
>;
const itemServers = parseList(body?.servers);
const finalServers = originServer ? Array.from(new Set([originServer, ...itemServers])) : itemServers;
const finalServers = originServer
? Array.from(new Set([originServer, ...itemServers]))
: itemServers;
const item = await createAdminItem({
item_name: name,
category,
rarity: typeof body?.rarity === "string" ? body.rarity.trim() || null : null,
rarity:
typeof body?.rarity === "string" ? body.rarity.trim() || null : null,
image_url: imageUrl,
image_local_path: imageLocalPath,
level: typeof body?.level === "number" ? body.level : null,
@@ -177,7 +203,9 @@ export async function POST(request: NextRequest) {
servers: finalServers,
tags: parseList(body?.tags),
description:
typeof body?.description === "string" ? body.description.trim() || null : null,
typeof body?.description === "string"
? body.description.trim() || null
: null,
metadata,
source_table_name: category,
source_table_id: sqliteId,
@@ -188,7 +216,7 @@ export async function POST(request: NextRequest) {
success: true,
item,
},
{ status: 201 }
{ status: 201 },
);
} catch (error: any) {
console.error("[admin/items][POST] erro", error?.message);
@@ -198,7 +226,7 @@ export async function POST(request: NextRequest) {
error: "Falha ao criar item",
message: error?.message || "Erro desconhecido",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -216,7 +244,7 @@ export async function PUT(request: NextRequest) {
if (!Number.isFinite(id) || id <= 0) {
return NextResponse.json(
{ success: false, error: "ID inválido." },
{ status: 400 }
{ status: 400 },
);
}
@@ -224,44 +252,67 @@ export async function PUT(request: NextRequest) {
if (!existing) {
return NextResponse.json(
{ success: false, error: "Item não encontrado." },
{ status: 404 }
{ status: 404 },
);
}
const category = (typeof body?.category === "string" ? body.category.trim().toLowerCase() : existing.category) ?? existing.category;
const category =
(typeof body?.category === "string"
? body.category.trim().toLowerCase()
: existing.category) ?? existing.category;
if (category !== existing.category) {
return NextResponse.json(
{ success: false, error: "Alterar a categoria de um item não é suportado." },
{ status: 400 }
{
success: false,
error: "Alterar a categoria de um item não é suportado.",
},
{ status: 400 },
);
}
const dynamicFields = normalizeDynamicFields(category, body?.fields);
const imageUrl = typeof body?.imageUrl === "string" ? body.imageUrl.trim() : undefined;
const imageUrl =
typeof body?.imageUrl === "string" ? body.imageUrl.trim() : undefined;
const imageLocalPath =
typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() : undefined;
typeof body?.imageLocalPath === "string"
? body.imageLocalPath.trim()
: undefined;
if (existing.source_table_id && existing.source_table_name) {
const sqliteData: Record<string, unknown> = {
nome: typeof body?.name === "string" ? body.name.trim() : existing.item_name,
nome:
typeof body?.name === "string"
? body.name.trim()
: existing.item_name,
};
if (imageUrl !== undefined) sqliteData.image_url = imageUrl;
else sqliteData.image_url = existing.image_url;
if (imageLocalPath !== undefined) sqliteData.image_local_path = imageLocalPath;
if (imageLocalPath !== undefined)
sqliteData.image_local_path = imageLocalPath;
else sqliteData.image_local_path = existing.image_local_path;
for (const [key, value] of Object.entries(dynamicFields)) {
sqliteData[key] = value;
}
updateCategoryItem(existing.source_table_name, existing.source_table_id, sqliteData);
updateCategoryItem(
existing.source_table_name,
existing.source_table_id,
sqliteData,
);
}
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : existing.metadata;
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>;
const metadataBase =
typeof body?.metadata === "object" && body.metadata !== null
? body.metadata
: existing.metadata;
const metadata = { ...metadataBase, ...dynamicFields } as Record<
string,
unknown
>;
const item = await updateAdminItem(id, {
item_name: typeof body?.name === "string" ? body.name.trim() : undefined,
@@ -279,8 +330,8 @@ export async function PUT(request: NextRequest) {
typeof body?.description === "string"
? body.description.trim()
: body?.description === null
? null
: undefined,
? null
: undefined,
metadata,
});
@@ -289,7 +340,7 @@ export async function PUT(request: NextRequest) {
success: true,
item,
},
{ status: 200 }
{ status: 200 },
);
} catch (error: any) {
console.error("[admin/items][PUT] erro", error?.message);
@@ -299,8 +350,7 @@ export async function PUT(request: NextRequest) {
error: "Falha ao atualizar item",
message: error?.message || "Erro desconhecido",
},
{ status: 500 }
{ status: 500 },
);
}
}
+20 -24
View File
@@ -1,27 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { loginSchema } from "@/lib/validation-schemas";
import { randomUUID } from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
import { generateAccessToken, generateRefreshToken } from "@/lib/auth/jwt";
import { comparePassword } from "@/lib/auth/password";
import {
generateAccessToken,
generateRefreshToken,
} from "@/lib/auth/jwt";
getDefaultSubscriptionTier,
getSubscriptionTierById,
} from "@/lib/prisma/queries/subscription-tiers";
import {
getUserByEmail,
getUserByUsername,
updateLastLogin,
} from "@/lib/prisma/queries/users";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
import {
getDefaultSubscriptionTier,
getSubscriptionTierById,
} from "@/lib/prisma/queries/subscription-tiers";
checkRateLimit,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { setSession } from "@/lib/redis/session";
import { secureLogger } from "@/lib/secure-logger";
import { loginSchema } from "@/lib/validation-schemas";
export async function POST(request: NextRequest) {
try {
@@ -55,7 +52,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -77,7 +74,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Email/username ou senha incorretos",
},
{ status: 401 }
{ status: 401 },
);
}
@@ -88,7 +85,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Conta desativada. Entre em contato com o suporte.",
},
{ status: 403 }
{ status: 403 },
);
}
@@ -104,7 +101,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Erro interno. Tente novamente mais tarde.",
},
{ status: 500 }
{ status: 500 },
);
}
@@ -121,7 +118,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Email/username ou senha incorretos",
},
{ status: 401 }
{ status: 401 },
);
}
@@ -181,7 +178,7 @@ export async function POST(request: NextRequest) {
},
accessToken,
},
{ status: 200 }
{ status: 200 },
);
// Adicionar refresh token e sessionId como cookies httpOnly
@@ -220,8 +217,7 @@ export async function POST(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+8 -9
View File
@@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserById } from "@/lib/prisma/queries/users";
import { getUserCharacters } from "@/lib/prisma/queries/characters";
import { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
export async function GET(request: NextRequest) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
// Se retornou NextResponse, é um erro de autenticação
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
// Buscar dados do usuário
@@ -25,7 +25,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Usuário não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -66,7 +66,7 @@ export async function GET(request: NextRequest) {
createdAt: char.created_at,
})),
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -85,8 +85,7 @@ export async function GET(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+7 -8
View File
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { generateAccessToken, verifyRefreshToken } from "@/lib/auth/jwt";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { verifyRefreshToken, generateAccessToken } from "@/lib/auth/jwt";
export async function POST(request: NextRequest) {
try {
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Refresh token não fornecido",
},
{ status: 401 }
{ status: 401 },
);
}
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Refresh token inválido ou expirado",
},
{ status: 401 }
{ status: 401 },
);
response.cookies.delete("refreshToken");
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
success: true,
accessToken,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao renovar token", {
@@ -74,8 +74,7 @@ export async function POST(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+21 -30
View File
@@ -1,29 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
import { generateAccessToken, generateRefreshToken } from "@/lib/auth/jwt";
import { hashPassword } from "@/lib/auth/password";
import { isServerActive } from "@/lib/prisma/queries/servers";
import { getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers";
import { getUserByEmail, getUserByUsername } from "@/lib/prisma/queries/users";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { setSession } from "@/lib/redis/session";
import { secureLogger } from "@/lib/secure-logger";
import { registerSchema } from "@/lib/validation-schemas";
import { hashPassword } from "@/lib/auth/password";
import {
generateAccessToken,
generateRefreshToken,
} from "@/lib/auth/jwt";
import {
getUserByEmail,
getUserByUsername,
} from "@/lib/prisma/queries/users";
import {
isServerActive,
incrementUsersForServers,
} from "@/lib/prisma/queries/servers";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
import { createUser } from "@/lib/prisma/queries/users";
import { createCharacter } from "@/lib/prisma/queries/characters";
import { getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers";
export async function POST(request: NextRequest) {
try {
@@ -62,7 +51,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -76,7 +65,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Email já está em uso",
},
{ status: 409 }
{ status: 409 },
);
}
@@ -88,7 +77,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Username já está em uso",
},
{ status: 409 }
{ status: 409 },
);
}
@@ -104,7 +93,7 @@ export async function POST(request: NextRequest) {
success: false,
error: `Servidor "${serverName}" não existe ou não está ativo`,
},
{ status: 400 }
{ status: 400 },
);
}
}
@@ -114,8 +103,11 @@ export async function POST(request: NextRequest) {
// Criar usuário e personagens em transação atômica usando Prisma
const { prisma } = await import("@/lib/prisma/app");
const createdCharacters: Array<{ characterName: string; serverName: string }> = [];
const createdCharacters: Array<{
characterName: string;
serverName: string;
}> = [];
// Usar transação Prisma
const newUser = await prisma.$transaction(async (tx) => {
@@ -199,7 +191,7 @@ export async function POST(request: NextRequest) {
{
tierSlug: defaultTier?.slug ?? undefined,
tierLevel: defaultTier?.level ?? undefined,
}
},
);
// Criar sessão no Redis
@@ -235,7 +227,7 @@ export async function POST(request: NextRequest) {
},
accessToken,
},
{ status: 201 }
{ status: 201 },
);
// Adicionar refresh token e sessionId como cookies httpOnly
@@ -274,8 +266,7 @@ export async function POST(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+30 -23
View File
@@ -1,17 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { secureLogger } from "@/lib/secure-logger";
import { getSession, refreshSession, deleteSession, setSession } from "@/lib/redis/session";
import { type NextRequest, NextResponse } from "next/server";
import { verifyAccessToken } from "@/lib/auth/jwt";
import { getMainCharacter } from "@/lib/prisma/queries/characters";
import {
getDefaultSubscriptionTier,
getSubscriptionTierById,
} from "@/lib/prisma/queries/subscription-tiers";
import { getUserById } from "@/lib/prisma/queries/users";
import { getSubscriptionTierById, getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers";
import {
deleteSession,
getSession,
refreshSession,
setSession,
} from "@/lib/redis/session";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/auth/session
* Verifica e retorna dados da sessão atual
*/
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
try {
// Buscar sessionId do cookie
const cookieStore = await cookies();
@@ -24,7 +32,7 @@ export async function GET(request: NextRequest) {
authenticated: false,
error: "Nenhuma sessão encontrada",
},
{ status: 401 }
{ status: 401 },
);
}
@@ -39,7 +47,7 @@ export async function GET(request: NextRequest) {
authenticated: false,
error: "Sessão expirada ou inválida",
},
{ status: 401 }
{ status: 401 },
);
response.cookies.delete("sessionId");
@@ -58,21 +66,19 @@ export async function GET(request: NextRequest) {
authenticated: false,
error: "Token expirado",
},
{ status: 401 }
{ status: 401 },
);
response.cookies.delete("sessionId");
return response;
}
let tierInfo:
| {
subscription_tier_id: number | null;
name: string | null;
slug: string | null;
level: number | null;
}
| null = {
let tierInfo: {
subscription_tier_id: number | null;
name: string | null;
slug: string | null;
level: number | null;
} | null = {
subscription_tier_id: session.subscriptionTierId,
name: session.subscriptionTierName,
slug: session.subscriptionTierSlug,
@@ -86,7 +92,9 @@ export async function GET(request: NextRequest) {
) {
const dbUser = await getUserById(session.userId);
const fetchedTier = dbUser?.subscription_tier
? await getSubscriptionTierById(dbUser.subscription_tier.subscription_tier_id)
? await getSubscriptionTierById(
dbUser.subscription_tier.subscription_tier_id,
)
: await getDefaultSubscriptionTier();
tierInfo = fetchedTier
@@ -139,7 +147,7 @@ export async function GET(request: NextRequest) {
: null,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao verificar sessão", {
@@ -154,7 +162,7 @@ export async function GET(request: NextRequest) {
authenticated: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -163,7 +171,7 @@ export async function GET(request: NextRequest) {
* DELETE /api/auth/session
* Encerra a sessão atual
*/
export async function DELETE(request: NextRequest) {
export async function DELETE(_request: NextRequest) {
try {
const cookieStore = await cookies();
const sessionId = cookieStore.get("sessionId")?.value;
@@ -177,7 +185,7 @@ export async function DELETE(request: NextRequest) {
success: true,
message: "Sessão encerrada",
},
{ status: 200 }
{ status: 200 },
);
response.cookies.delete("sessionId");
@@ -193,8 +201,7 @@ export async function DELETE(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+40 -34
View File
@@ -3,13 +3,16 @@
* POST /api/chats/[chatId]/messages - Envia mensagem (fallback se Socket.io não estiver disponível)
*/
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
import { Message } from "@/lib/mongodb/models/Message";
import { sendMessageSchema, getMessagesSchema } from "@/lib/validation/chat-schemas";
import { secureLogger } from "@/lib/secure-logger";
import {
getMessagesSchema,
sendMessageSchema,
} from "@/lib/validation/chat-schemas";
/**
* GET /api/chats/[chatId]/messages
@@ -17,7 +20,7 @@ import { secureLogger } from "@/lib/secure-logger";
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
{ params }: { params: Promise<{ chatId: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -26,7 +29,7 @@ export async function GET(
const { chatId } = await params;
const { searchParams } = new URL(request.url);
const queryValidation = getMessagesSchema.safeParse({
page: searchParams.get("page"),
limit: searchParams.get("limit"),
@@ -39,7 +42,7 @@ export async function GET(
error: "Parâmetros inválidos",
details: queryValidation.error.issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -53,14 +56,17 @@ export async function GET(
if (!chat) {
return NextResponse.json(
{ success: false, error: "Chat não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para acessar este chat" },
{ status: 403 }
{
success: false,
error: "Você não tem permissão para acessar este chat",
},
{ status: 403 },
);
}
@@ -88,7 +94,7 @@ export async function GET(
},
{
$set: { read_at: new Date() },
}
},
);
// Atualizar contador de não lidas
@@ -100,19 +106,17 @@ export async function GET(
await chat.save();
// Serializar mensagens (ordem reversa para mostrar mais antigas primeiro)
const serializedMessages = messages
.reverse()
.map((msg) => ({
_id: msg._id.toString(),
chat_id: msg.chat_id,
sender_id: msg.sender_id,
content: msg.content,
created_at: msg.created_at.toISOString(),
read_at: msg.read_at?.toISOString() || null,
edited_at: msg.edited_at?.toISOString() || null,
message_type: msg.message_type,
metadata: msg.metadata || null,
}));
const serializedMessages = messages.reverse().map((msg) => ({
_id: msg._id.toString(),
chat_id: msg.chat_id,
sender_id: msg.sender_id,
content: msg.content,
created_at: msg.created_at.toISOString(),
read_at: msg.read_at?.toISOString() || null,
edited_at: msg.edited_at?.toISOString() || null,
message_type: msg.message_type,
metadata: msg.metadata || null,
}));
return NextResponse.json(
{
@@ -125,7 +129,7 @@ export async function GET(
totalPages: Math.ceil(total / limit),
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar mensagens", {
@@ -135,7 +139,7 @@ export async function GET(
return NextResponse.json(
{ success: false, error: "Erro ao buscar mensagens" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -146,7 +150,7 @@ export async function GET(
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
{ params }: { params: Promise<{ chatId: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -164,7 +168,7 @@ export async function POST(
error: "Dados inválidos",
details: validationResult.error.issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -178,14 +182,17 @@ export async function POST(
if (!chat) {
return NextResponse.json(
{ success: false, error: "Chat não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para enviar mensagens neste chat" },
{ status: 403 }
{
success: false,
error: "Você não tem permissão para enviar mensagens neste chat",
},
{ status: 403 },
);
}
@@ -202,7 +209,7 @@ export async function POST(
// Atualizar chat
chat.last_message_at = new Date();
chat.last_message_preview = content.substring(0, 100);
if (chat.buyer_id === auth.userId) {
chat.seller_unread_count += 1;
} else {
@@ -228,7 +235,7 @@ export async function POST(
},
});
}
} catch (socketError) {
} catch (_socketError) {
// Socket.io não disponível, mensagem já foi salva
secureLogger.warn("Socket.io não disponível, mensagem salva via REST", {
chatId,
@@ -248,7 +255,7 @@ export async function POST(
message_type: message.message_type,
},
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
secureLogger.error("❌ Erro ao enviar mensagem", {
@@ -258,8 +265,7 @@ export async function POST(
return NextResponse.json(
{ success: false, error: "Erro ao enviar mensagem" },
{ status: 500 }
{ status: 500 },
);
}
}
+10 -8
View File
@@ -2,7 +2,7 @@
* GET /api/chats/[chatId] - Busca detalhes de um chat específico
*/
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
@@ -10,7 +10,7 @@ import { secureLogger } from "@/lib/secure-logger";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
{ params }: { params: Promise<{ chatId: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -27,15 +27,18 @@ export async function GET(
if (!chat) {
return NextResponse.json(
{ success: false, error: "Chat não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
// Verificar se o usuário pertence ao chat
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para acessar este chat" },
{ status: 403 }
{
success: false,
error: "Você não tem permissão para acessar este chat",
},
{ status: 403 },
);
}
@@ -59,7 +62,7 @@ export async function GET(
seller_cache: chat.seller_cache,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar chat", {
@@ -69,8 +72,7 @@ export async function GET(
return NextResponse.json(
{ success: false, error: "Erro ao buscar chat" },
{ status: 500 }
{ status: 500 },
);
}
}
+6 -6
View File
@@ -2,7 +2,7 @@
* POST /api/chats/mark-all-read
* Marca todas as mensagens de todos os chats do usuário como lidas
*/
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
// Para cada chat, marcar mensagens não lidas como lidas
const updatePromises = chats.map(async (chat) => {
const chatId = chat.chat_id;
// Marcar mensagens como lidas
await Message.updateMany(
{
@@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
},
{
$set: { read_at: new Date() },
}
},
);
// Zerar contador de não lidas
@@ -45,7 +45,7 @@ export async function POST(request: NextRequest) {
} else {
chat.seller_unread_count = 0;
}
return chat.save();
});
@@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
success: true,
message: "Todos os chats marcados como lidos",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar chats como lidos", {
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao marcar como lido" },
{ status: 500 }
{ status: 500 },
);
}
}
+17 -16
View File
@@ -3,15 +3,15 @@
* POST /api/chats - Cria novo chat
*/
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
import { createChatSchema } from "@/lib/validation/chat-schemas";
import { getListingById } from "@/lib/prisma/queries/listings";
import { getMainCharacter } from "@/lib/prisma/queries/characters";
import { getListingById } from "@/lib/prisma/queries/listings";
import { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
import { createChatSchema } from "@/lib/validation/chat-schemas";
/**
* GET /api/chats
@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
chats: serializedChats,
count: serializedChats.length,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar chats", {
@@ -67,7 +67,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao buscar chats" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
error: "Dados inválidos",
details: validationResult.error.issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -113,7 +113,7 @@ export async function POST(request: NextRequest) {
if (listing.seller_id === auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não pode criar um chat com você mesmo" },
{ status: 400 }
{ status: 400 },
);
}
@@ -121,7 +121,7 @@ export async function POST(request: NextRequest) {
if (listing.listing_status !== "active") {
return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para chat" },
{ status: 400 }
{ status: 400 },
);
}
@@ -154,7 +154,7 @@ export async function POST(request: NextRequest) {
},
already_exists: true,
},
{ status: 200 }
{ status: 200 },
);
}
@@ -163,7 +163,7 @@ export async function POST(request: NextRequest) {
if (!buyer) {
return NextResponse.json(
{ success: false, error: "Usuário não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -174,7 +174,7 @@ export async function POST(request: NextRequest) {
if (!seller) {
return NextResponse.json(
{ success: false, error: "Vendedor não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -191,7 +191,9 @@ export async function POST(request: NextRequest) {
item_image_url: null, // Imagem será obtida via item_id se necessário
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
price_tibia_coins: listing.price_tibia_coins || null,
price_gold_coins: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
price_gold_coins: listing.price_gold_coins
? Number(listing.price_gold_coins)
: null,
server_name: listing.server_name,
listing_status: listing.listing_status,
},
@@ -248,7 +250,7 @@ export async function POST(request: NextRequest) {
},
already_exists: false,
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
secureLogger.error("❌ Erro ao criar chat", {
@@ -258,8 +260,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao criar chat" },
{ status: 500 }
{ status: 500 },
);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
* GET /api/chats/unread-count
* Retorna contagem de usuários únicos com mensagens não vistas
*/
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
@@ -54,7 +54,7 @@ export async function GET(request: NextRequest) {
return total + chat.seller_unread_count;
}, 0),
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar contagem de chats não lidos", {
@@ -64,7 +64,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao buscar contagem" },
{ status: 500 }
{ status: 500 },
);
}
}
+8 -8
View File
@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
import { createDonationSchema } from "@/lib/validation-schemas";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { createDonationSchema } from "@/lib/validation-schemas";
import { DONATION_PRODUCTS } from "@/types/donation";
export async function POST(request: NextRequest) {
try {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -72,8 +72,8 @@ export async function POST(request: NextRequest) {
if (validatedData.tier === "custom") {
// Converter para centavos
finalAmount = Math.round(validatedData.customAmount! * 100);
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed(
2
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount?.toFixed(
2,
)}`;
secureLogger.info("💰 Valor customizado calculado", {
@@ -161,7 +161,7 @@ export async function POST(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { getPaymentStatus } from "@/lib/payment-cache";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!pixId) {
return NextResponse.json(
{ error: "pixId é obrigatório" },
{ status: 400 }
{ status: 400 },
);
}
@@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
console.error("Erro ao verificar status do pagamento:", error);
return NextResponse.json(
{ error: "Erro ao verificar status" },
{ status: 500 }
{ status: 500 },
);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!billingId) {
return NextResponse.json(
{ success: false, error: "ID da cobrança é obrigatório" },
{ status: 400 }
{ status: 400 },
);
}
@@ -42,7 +42,7 @@ export async function GET(request: NextRequest) {
error:
error instanceof Error ? error.message : "Erro ao verificar cobrança",
},
{ status: 500 }
{ status: 500 },
);
}
}
+31 -23
View File
@@ -1,9 +1,9 @@
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { getItemById } from "@/lib/items-db";
import type { NextRequest } from "next/server";
import { getItemByIdUnified } from "@/lib/db/queries/items";
import { buildItemImagePath } from "@/lib/item-images";
import { getItemById } from "@/lib/items-db";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
@@ -17,12 +17,12 @@ export async function GET(req: NextRequest) {
if (itemId) {
try {
const itemIdNum = parseInt(itemId, 10);
if (!isNaN(itemIdNum)) {
if (!Number.isNaN(itemIdNum)) {
// Tentar primeiro buscar na tabela unificada PostgreSQL
// Se a tabela não existir, retornará null silenciosamente
let item: any = null;
item = await getItemByIdUnified(itemIdNum);
// Se encontrou na tabela unificada, validar o nome se fornecido
if (item && itemName && item.item_name) {
if (item.item_name.toLowerCase() !== itemName.toLowerCase()) {
@@ -30,24 +30,28 @@ export async function GET(req: NextRequest) {
item = null;
}
}
// Se não encontrou na tabela unificada, tentar SQLite legado
// Passar itemName para buscar por ID E NOME (evita ambiguidade)
if (!item) {
item = getItemById(itemIdNum, itemName || undefined);
}
if (item) {
// PRIORIDADE: 1. image_local_path (local), 2. image_url (externa), 3. image (fallback)
const imageLocalPath = item.image_local_path || "";
const imageUrl = item.image_url || "";
const image = item.image || "";
// Tentar primeiro a imagem local
if (imageLocalPath) {
const normalizedPath = imageLocalPath.replace(/\\/g, "/");
const category = (item.categoria || item.category || "other").toLowerCase();
const category = (
item.categoria ||
item.category ||
"other"
).toLowerCase();
// Se já for um caminho completo (começa com data/), usar diretamente
if (normalizedPath.startsWith("data/")) {
imagePath = normalizedPath;
@@ -55,12 +59,12 @@ export async function GET(req: NextRequest) {
// Construir o caminho usando a função helper
imagePath = buildItemImagePath(category, normalizedPath);
}
// Verificar se o arquivo existe antes de usar
if (imagePath) {
const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), imagePath);
if (resolved.startsWith(baseDir)) {
try {
await fs.promises.access(resolved);
@@ -72,11 +76,14 @@ export async function GET(req: NextRequest) {
}
}
}
// Se não encontrou imagem local ou não existe, usar URL externa como fallback
if (!imagePath && (imageUrl || image)) {
const externalUrl = imageUrl || image;
if (externalUrl.startsWith("http://") || externalUrl.startsWith("https://")) {
if (
externalUrl.startsWith("http://") ||
externalUrl.startsWith("https://")
) {
return Response.redirect(externalUrl, 302);
}
}
@@ -99,7 +106,7 @@ export async function GET(req: NextRequest) {
// Permitir apenas caminhos sob data/images para segurança
const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), imagePath);
if (!resolved.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 });
}
@@ -111,18 +118,19 @@ export async function GET(req: NextRequest) {
ext === ".gif"
? "image/gif"
: ext === ".png"
? "image/png"
: ext === ".webp"
? "image/webp"
: ext === ".jpg" || ext === ".jpeg"
? "image/jpeg"
: "application/octet-stream";
return new Response(file, { status: 200, headers: { "Content-Type": contentType } });
? "image/png"
: ext === ".webp"
? "image/webp"
: ext === ".jpg" || ext === ".jpeg"
? "image/jpeg"
: "application/octet-stream";
return new Response(file, {
status: 200,
headers: { "Content-Type": contentType },
});
} catch (error) {
console.error("[api/images] Erro ao ler arquivo:", error);
// Retornar 404 silenciosamente para não gerar erros no console
return new Response("Not Found", { status: 404 });
}
}
+2 -7
View File
@@ -1,4 +1,4 @@
import { NextRequest } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma/app";
export async function GET(_req: NextRequest) {
@@ -15,11 +15,6 @@ export async function GET(_req: NextRequest) {
.filter((category): category is string => Boolean(category)),
});
} catch (e: any) {
return Response.json(
{ error: e?.message || "Erro" },
{ status: 500 }
);
return Response.json({ error: e?.message || "Erro" }, { status: 500 });
}
}
+19 -9
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { searchItems, type ItemSort } from "@/lib/prisma/queries/items";
import type { NextRequest } from "next/server";
import { buildPublicItemImageUrl } from "@/lib/item-images";
import { type ItemSort, searchItems } from "@/lib/prisma/queries/items";
function parseArray(param: string | null): string[] {
if (!param) return [];
@@ -28,7 +28,8 @@ export async function GET(req: NextRequest) {
const rarities = parseArray(searchParams.get("rarity"));
const server = searchParams.get("server") || undefined;
const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase();
const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) || "name") as ItemSort;
const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) ||
"name") as ItemSort;
const page = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "24");
@@ -48,7 +49,11 @@ export async function GET(req: NextRequest) {
return { ...item, image_url: img, image: img };
}
if (img.startsWith("data/")) {
return { ...item, image_url: img, image: `/api/images?path=${encodeURIComponent(img)}` };
return {
...item,
image_url: img,
image: `/api/images?path=${encodeURIComponent(img)}`,
};
}
const category = item.category || "other";
return {
@@ -60,8 +65,15 @@ export async function GET(req: NextRequest) {
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
return Response.json(
{ items: itemsWithImage, page, pageSize, total, totalPages, sort: sortParam },
{ status: 200 }
{
items: itemsWithImage,
page,
pageSize,
total,
totalPages,
sort: sortParam,
},
{ status: 200 },
);
} catch (error: any) {
console.error("[api/items] Erro:", error?.message, error?.stack);
@@ -70,9 +82,7 @@ export async function GET(req: NextRequest) {
error: "Falha ao consultar itens",
message: error?.message || "Erro desconhecido",
},
{ status: 500 }
{ status: 500 },
);
}
}
+11 -12
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/:id/activate
@@ -9,7 +9,7 @@ import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -24,13 +24,13 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -43,7 +43,7 @@ export async function POST(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -53,7 +53,7 @@ export async function POST(
success: false,
error: "Você não tem permissão para reativar este anúncio",
},
{ status: 403 }
{ status: 403 },
);
}
@@ -63,7 +63,7 @@ export async function POST(
success: false,
error: "Apenas anúncios pausados podem ser reativados",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -78,7 +78,7 @@ export async function POST(
success: false,
error: "Erro ao reativar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
@@ -93,7 +93,7 @@ export async function POST(
listing: updated,
message: "Anúncio reativado com sucesso",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao reativar anúncio", {
@@ -105,8 +105,7 @@ export async function POST(
success: false,
error: "Erro ao reativar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
+29 -18
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { placeBid } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/[id]/bid
@@ -10,7 +10,7 @@ import { createNotification } from "@/lib/prisma/queries/notifications";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -19,10 +19,10 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -31,34 +31,42 @@ export async function POST(
if (!amount || typeof amount !== "number" || amount <= 0) {
return NextResponse.json(
{ success: false, error: "Valor do lance é obrigatório e deve ser maior que zero" },
{ status: 400 }
{
success: false,
error: "Valor do lance é obrigatório e deve ser maior que zero",
},
{ status: 400 },
);
}
if (!payment_method || !["pix", "tc", "gp"].includes(payment_method)) {
return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" },
{ status: 400 }
{ status: 400 },
);
}
// Colocar lance
const result = await placeBid(listingId, auth.userId, amount, payment_method);
const result = await placeBid(
listingId,
auth.userId,
amount,
payment_method,
);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
{ status: 400 },
);
}
// Criar notificação para o vendedor
await createNotification({
user_id: result.listing!.seller_id,
user_id: result.listing?.seller_id,
notification_type: "bid",
title: "Novo lance!",
message: `Novo lance de R$ ${amount.toFixed(2)} no item "${result.listing!.item_name}"`,
message: `Novo lance de R$ ${amount.toFixed(2)} no item "${result.listing?.item_name}"`,
related_id: listingId,
send_whatsapp: false,
});
@@ -69,7 +77,7 @@ export async function POST(
user_id: result.previousBidderId,
notification_type: "bid",
title: "Lance superado",
message: `Seu lance foi superado no item "${result.listing!.item_name}"`,
message: `Seu lance foi superado no item "${result.listing?.item_name}"`,
related_id: listingId,
send_whatsapp: false,
});
@@ -84,8 +92,12 @@ export async function POST(
metadata: {
bid_amount: amount,
payment_method: payment_method,
previous_bid: result.listing!.current_bid_amount ? Number(result.listing!.current_bid_amount) : null,
min_increment: result.listing!.min_bid_increment ? Number(result.listing!.min_bid_increment) : null,
previous_bid: result.listing?.current_bid_amount
? Number(result.listing?.current_bid_amount)
: null,
min_increment: result.listing?.min_bid_increment
? Number(result.listing?.min_bid_increment)
: null,
},
});
@@ -102,7 +114,7 @@ export async function POST(
listing: result.listing,
message: "Lance realizado com sucesso",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao processar lance", {
@@ -112,8 +124,7 @@ export async function POST(
return NextResponse.json(
{ success: false, error: "Erro ao processar lance" },
{ status: 500 }
{ status: 500 },
);
}
}
+10 -11
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { prisma } from "@/lib/prisma/app";
import { getListingById } from "@/lib/prisma/queries/listings";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/listings/[id]/bids
@@ -11,7 +11,7 @@ import { trackEvent } from "@/lib/prisma/queries/telemetry";
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -19,10 +19,10 @@ export async function GET(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -31,14 +31,14 @@ export async function GET(
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
if (!listing.is_auction) {
return NextResponse.json(
{ success: false, error: "Este anúncio não é um leilão" },
{ status: 400 }
{ status: 400 },
);
}
@@ -92,7 +92,7 @@ export async function GET(
bids: formattedBids,
total: formattedBids.length,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar histórico de lances", {
@@ -102,8 +102,7 @@ export async function GET(
return NextResponse.json(
{ success: false, error: "Erro ao buscar histórico de lances" },
{ status: 500 }
{ status: 500 },
);
}
}
+21 -19
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites";
import { getListingById } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/:id/favorite
@@ -10,7 +10,7 @@ import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -25,13 +25,13 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -44,17 +44,20 @@ export async function POST(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
// Toggle favorite
const favorited = await toggleFavorite(auth.userId, listingId);
secureLogger.info(`✅ Anúncio ${favorited ? "favoritado" : "desfavoritado"}`, {
listingId,
userId: auth.userId,
});
secureLogger.info(
`✅ Anúncio ${favorited ? "favoritado" : "desfavoritado"}`,
{
listingId,
userId: auth.userId,
},
);
return NextResponse.json(
{
@@ -62,7 +65,7 @@ export async function POST(
favorited,
message: favorited ? "Anúncio favoritado" : "Anúncio desfavoritado",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao favoritar/desfavoritar anúncio", {
@@ -75,7 +78,7 @@ export async function POST(
success: false,
error: "Erro ao favoritar/desfavoritar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -86,7 +89,7 @@ export async function POST(
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -101,13 +104,13 @@ export async function GET(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -118,7 +121,7 @@ export async function GET(
success: true,
favorited,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao verificar favorito", {
@@ -130,8 +133,7 @@ export async function GET(
success: false,
error: "Erro ao verificar favorito",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
import { Message } from "@/lib/mongodb/models/Message";
import { getMainCharacter } from "@/lib/prisma/queries/characters";
import { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import {
getPurchaseIntentionById,
updatePurchaseIntentionStatus,
} from "@/lib/prisma/queries/purchase-intentions";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat";
import { Message } from "@/lib/mongodb/models/Message";
import { getUserById } from "@/lib/prisma/queries/users";
import { getMainCharacter } from "@/lib/prisma/queries/characters";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/listings/[id]/intentions/[intentionId]
@@ -19,7 +19,7 @@ import { getMainCharacter } from "@/lib/prisma/queries/characters";
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; intentionId: string }> }
{ params }: { params: Promise<{ id: string; intentionId: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -27,10 +27,10 @@ export async function GET(
const { intentionId: intentionIdParam } = await params;
const intentionId = parseInt(intentionIdParam, 10);
if (isNaN(intentionId)) {
if (Number.isNaN(intentionId)) {
return NextResponse.json(
{ success: false, error: "ID de intenção inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -38,15 +38,21 @@ export async function GET(
if (!intention) {
return NextResponse.json(
{ success: false, error: "Intenção não encontrada" },
{ status: 404 }
{ status: 404 },
);
}
// Verificar permissões: vendedor ou comprador
if (intention.listing?.seller_id !== authResult.userId && intention.buyer_id !== authResult.userId) {
if (
intention.listing?.seller_id !== authResult.userId &&
intention.buyer_id !== authResult.userId
) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para ver esta intenção" },
{ status: 403 }
{
success: false,
error: "Você não tem permissão para ver esta intenção",
},
{ status: 403 },
);
}
@@ -55,7 +61,7 @@ export async function GET(
success: true,
intention,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar intenção", {
@@ -65,7 +71,7 @@ export async function GET(
return NextResponse.json(
{ success: false, error: "Erro ao buscar intenção" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -76,7 +82,7 @@ export async function GET(
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string; intentionId: string }> }
{ params }: { params: Promise<{ id: string; intentionId: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -84,10 +90,10 @@ export async function PATCH(
const { intentionId: intentionIdParam } = await params;
const intentionId = parseInt(intentionIdParam, 10);
if (isNaN(intentionId)) {
if (Number.isNaN(intentionId)) {
return NextResponse.json(
{ success: false, error: "ID de intenção inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -100,8 +106,11 @@ export async function PATCH(
if (!status || !["accepted", "rejected"].includes(status)) {
return NextResponse.json(
{ success: false, error: "Status inválido. Use 'accepted' ou 'rejected'" },
{ status: 400 }
{
success: false,
error: "Status inválido. Use 'accepted' ou 'rejected'",
},
{ status: 400 },
);
}
@@ -110,15 +119,18 @@ export async function PATCH(
if (!intention) {
return NextResponse.json(
{ success: false, error: "Intenção não encontrada" },
{ status: 404 }
{ status: 404 },
);
}
// Verificar se o usuário é o vendedor
if (intention.listing?.seller_id !== authResult.userId) {
return NextResponse.json(
{ success: false, error: "Apenas o vendedor pode aceitar ou rejeitar intenções" },
{ status: 403 }
{
success: false,
error: "Apenas o vendedor pode aceitar ou rejeitar intenções",
},
{ status: 403 },
);
}
@@ -126,16 +138,20 @@ export async function PATCH(
if (intention.status !== "pending") {
return NextResponse.json(
{ success: false, error: `Esta intenção já foi ${intention.status}` },
{ status: 400 }
{ status: 400 },
);
}
// Verificar se o anúncio ainda está disponível (ativo ou em escrow)
const listing = await getListingById(intention.listing_id);
if (!listing || (listing.listing_status !== "active" && listing.listing_status !== "in_escrow")) {
if (
!listing ||
(listing.listing_status !== "active" &&
listing.listing_status !== "in_escrow")
) {
return NextResponse.json(
{ success: false, error: "Anúncio não está mais disponível" },
{ status: 400 }
{ status: 400 },
);
}
@@ -143,7 +159,7 @@ export async function PATCH(
const updatedIntention = await updatePurchaseIntentionStatus(
intentionId,
status,
sellerResponse
sellerResponse,
);
await sendSellerResponseToChat({
@@ -159,19 +175,23 @@ export async function PATCH(
user_id: intention.buyer_id,
notification_type: status === "accepted" ? "purchase" : "system",
title: status === "accepted" ? "Intenção aceita!" : "Intenção rejeitada",
message: status === "accepted"
? `O vendedor aceitou sua intenção de compra do item "${intention.listing?.item_name}"`
: `O vendedor rejeitou sua intenção de compra do item "${intention.listing?.item_name}"`,
message:
status === "accepted"
? `O vendedor aceitou sua intenção de compra do item "${intention.listing?.item_name}"`
: `O vendedor rejeitou sua intenção de compra do item "${intention.listing?.item_name}"`,
related_id: intention.listing_id,
send_whatsapp: false,
});
secureLogger.info(`✅ Intenção ${status === "accepted" ? "aceita" : "rejeitada"}`, {
userId: authResult.userId,
intentionId,
listingId: intention.listing_id,
buyerId: intention.buyer_id,
});
secureLogger.info(
`✅ Intenção ${status === "accepted" ? "aceita" : "rejeitada"}`,
{
userId: authResult.userId,
intentionId,
listingId: intention.listing_id,
buyerId: intention.buyer_id,
},
);
return NextResponse.json(
{
@@ -179,7 +199,7 @@ export async function PATCH(
intention: updatedIntention,
message: `Intenção ${status === "accepted" ? "aceita" : "rejeitada"} com sucesso`,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao atualizar intenção", {
@@ -189,7 +209,7 @@ export async function PATCH(
return NextResponse.json(
{ success: false, error: "Erro ao atualizar intenção" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -202,7 +222,10 @@ async function sendSellerResponseToChat({
sellerId,
}: {
listing: Awaited<ReturnType<typeof getListingById>>;
intention: Exclude<Awaited<ReturnType<typeof getPurchaseIntentionById>>, null>;
intention: Exclude<
Awaited<ReturnType<typeof getPurchaseIntentionById>>,
null
>;
status: "accepted" | "rejected";
sellerResponse?: string;
sellerId: number;
@@ -225,12 +248,13 @@ async function sendSellerResponseToChat({
let chat = await Chat.findOne({ chat_id: chatId });
if (!chat) {
const [buyer, seller, buyerCharacter, sellerCharacter] = await Promise.all([
getUserById(intention.buyer_id),
getUserById(listing.seller_id),
getMainCharacter(intention.buyer_id),
getMainCharacter(listing.seller_id),
]);
const [buyer, seller, buyerCharacter, sellerCharacter] =
await Promise.all([
getUserById(intention.buyer_id),
getUserById(listing.seller_id),
getMainCharacter(intention.buyer_id),
getMainCharacter(listing.seller_id),
]);
chat = new Chat({
chat_id: chatId,
@@ -284,4 +308,3 @@ async function sendSellerResponseToChat({
});
}
}
+67 -43
View File
@@ -1,15 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import {
createPurchaseIntention,
getPurchaseIntentionsByListing,
hasPendingIntention,
getPendingIntentionByBuyerAndListing,
getPurchaseIntentionsByListing,
} from "@/lib/prisma/queries/purchase-intentions";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/[id]/intentions
@@ -17,7 +16,7 @@ import { trackEvent } from "@/lib/prisma/queries/telemetry";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -26,10 +25,10 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -41,10 +40,13 @@ export async function POST(
custom_offer_currency,
} = body;
if (!payment_method || !["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)) {
if (
!payment_method ||
!["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)
) {
return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -53,15 +55,19 @@ export async function POST(
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
// Validar se não é o vendedor
if (listing.seller_id === auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não pode criar uma intenção de compra para seu próprio anúncio" },
{ status: 403 }
{
success: false,
error:
"Você não pode criar uma intenção de compra para seu próprio anúncio",
},
{ status: 403 },
);
}
@@ -69,15 +75,18 @@ export async function POST(
if (listing.listing_status !== "active") {
return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para compra" },
{ status: 400 }
{ status: 400 },
);
}
// Validar se é leilão (leilões não podem ter intenções, apenas lances)
if (listing.is_auction) {
return NextResponse.json(
{ success: false, error: "Este item está em leilão. Use a funcionalidade de lance." },
{ status: 400 }
{
success: false,
error: "Este item está em leilão. Use a funcionalidade de lance.",
},
{ status: 400 },
);
}
@@ -95,7 +104,7 @@ export async function POST(
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -107,9 +116,10 @@ export async function POST(
return NextResponse.json(
{
success: false,
error: "Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).",
error:
"Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -118,14 +128,16 @@ export async function POST(
return NextResponse.json(
{
success: false,
error: "Valor da oferta personalizada deve ser numérico e maior que zero.",
error:
"Valor da oferta personalizada deve ser numérico e maior que zero.",
},
{ status: 400 }
{ status: 400 },
);
}
normalizedOfferCurrency =
custom_offer_currency && ["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency)
custom_offer_currency &&
["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency)
? custom_offer_currency
: payment_method;
@@ -136,7 +148,10 @@ export async function POST(
}
// Verificar se já existe uma intenção pendente
const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId);
const existingIntention = await getPendingIntentionByBuyerAndListing(
listingId,
auth.userId,
);
if (existingIntention) {
// Se já existe e ainda pode trocar o método
if (existingIntention.payment_method_change_count < 1) {
@@ -144,23 +159,27 @@ export async function POST(
return NextResponse.json(
{
success: false,
error: "Você já possui uma intenção de compra pendente para este anúncio",
error:
"Você já possui uma intenção de compra pendente para este anúncio",
existing_intention_id: existingIntention.intention_id,
can_change_payment_method: true,
payment_method_change_count: existingIntention.payment_method_change_count,
message: "Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento",
payment_method_change_count:
existingIntention.payment_method_change_count,
message:
"Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento",
},
{ status: 400 }
{ status: 400 },
);
} else {
// Já atingiu o limite de trocas
return NextResponse.json(
{
success: false,
error: "Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento",
error:
"Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento",
existing_intention_id: existingIntention.intention_id,
},
{ status: 400 }
{ status: 400 },
);
}
}
@@ -197,7 +216,9 @@ export async function POST(
payment_method,
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
listing_price_tc: listing.price_tibia_coins || null,
listing_price_gp: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
listing_price_gp: listing.price_gold_coins
? Number(listing.price_gold_coins)
: null,
custom_offer_amount: normalizedOfferValue,
custom_offer_currency: normalizedOfferCurrency,
},
@@ -214,9 +235,10 @@ export async function POST(
{
success: true,
intention,
message: "Intenção de compra criada com sucesso. O vendedor será notificado.",
message:
"Intenção de compra criada com sucesso. O vendedor será notificado.",
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
secureLogger.error("❌ Erro ao criar intenção de compra", {
@@ -226,7 +248,7 @@ export async function POST(
return NextResponse.json(
{ success: false, error: "Erro ao criar intenção de compra" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -237,19 +259,19 @@ export async function POST(
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult;
const auth = authResult;
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -258,15 +280,18 @@ export async function GET(
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
// Apenas o vendedor pode ver as intenções
if (listing.seller_id !== auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não tem permissão para ver as intenções deste anúncio" },
{ status: 403 }
{
success: false,
error: "Você não tem permissão para ver as intenções deste anúncio",
},
{ status: 403 },
);
}
@@ -297,7 +322,7 @@ export async function GET(
intentions,
count: intentions.length,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar intenções de compra", {
@@ -307,8 +332,7 @@ export async function GET(
return NextResponse.json(
{ success: false, error: "Erro ao buscar intenções de compra" },
{ status: 500 }
{ status: 500 },
);
}
}
+36 -21
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
import { getListingById } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/:id/mark-sold
@@ -9,7 +9,7 @@ import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -24,13 +24,13 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -43,7 +43,7 @@ export async function POST(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -53,18 +53,22 @@ export async function POST(
success: false,
error: "Você não tem permissão para marcar este anúncio como vendido",
},
{ status: 403 }
{ status: 403 },
);
}
// Verificar se o anúncio pode ser marcado como vendido
if (listing.listing_status !== "active" && listing.listing_status !== "in_escrow") {
if (
listing.listing_status !== "active" &&
listing.listing_status !== "in_escrow"
) {
return NextResponse.json(
{
success: false,
error: "Apenas anúncios ativos ou em escrow podem ser marcados como vendidos",
error:
"Apenas anúncios ativos ou em escrow podem ser marcados como vendidos",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -90,12 +94,24 @@ export async function POST(
// Converter campos para formato esperado
const updated = {
...updatedListing,
price_pix: updatedListing.price_pix ? Number(updatedListing.price_pix) : null,
current_bid_amount: updatedListing.current_bid_amount ? Number(updatedListing.current_bid_amount) : null,
min_bid_increment: updatedListing.min_bid_increment ? Number(updatedListing.min_bid_increment) : null,
initial_price_pix: updatedListing.initial_price_pix ? Number(updatedListing.initial_price_pix) : null,
price_gold_coins: updatedListing.price_gold_coins ? Number(updatedListing.price_gold_coins) : null,
initial_price_gold_coins: updatedListing.initial_price_gold_coins ? Number(updatedListing.initial_price_gold_coins) : null,
price_pix: updatedListing.price_pix
? Number(updatedListing.price_pix)
: null,
current_bid_amount: updatedListing.current_bid_amount
? Number(updatedListing.current_bid_amount)
: null,
min_bid_increment: updatedListing.min_bid_increment
? Number(updatedListing.min_bid_increment)
: null,
initial_price_pix: updatedListing.initial_price_pix
? Number(updatedListing.initial_price_pix)
: null,
price_gold_coins: updatedListing.price_gold_coins
? Number(updatedListing.price_gold_coins)
: null,
initial_price_gold_coins: updatedListing.initial_price_gold_coins
? Number(updatedListing.initial_price_gold_coins)
: null,
seller_character_name: character?.character_name || null,
};
@@ -105,7 +121,7 @@ export async function POST(
success: false,
error: "Erro ao marcar anúncio como vendido",
},
{ status: 500 }
{ status: 500 },
);
}
@@ -120,7 +136,7 @@ export async function POST(
listing: updated,
message: "Anúncio marcado como vendido com sucesso",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar anúncio como vendido", {
@@ -132,8 +148,7 @@ export async function POST(
success: false,
error: "Erro ao marcar anúncio como vendido",
},
{ status: 500 }
{ status: 500 },
);
}
}
+11 -12
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/:id/pause
@@ -9,7 +9,7 @@ import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -24,13 +24,13 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -43,7 +43,7 @@ export async function POST(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -53,7 +53,7 @@ export async function POST(
success: false,
error: "Você não tem permissão para pausar este anúncio",
},
{ status: 403 }
{ status: 403 },
);
}
@@ -63,7 +63,7 @@ export async function POST(
success: false,
error: "Apenas anúncios ativos podem ser pausados",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -79,7 +79,7 @@ export async function POST(
success: false,
error: "Erro ao pausar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
@@ -94,7 +94,7 @@ export async function POST(
listing: updated,
message: "Anúncio pausado com sucesso",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao pausar anúncio", {
@@ -106,8 +106,7 @@ export async function POST(
success: false,
error: "Erro ao pausar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
+44 -30
View File
@@ -1,14 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import {
createPurchaseIntention,
hasPendingIntention,
getPendingIntentionByBuyerAndListing,
} from "@/lib/prisma/queries/purchase-intentions";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/[id]/purchase
@@ -16,7 +15,7 @@ import { trackEvent } from "@/lib/prisma/queries/telemetry";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -25,10 +24,10 @@ export async function POST(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -43,7 +42,7 @@ export async function POST(
if (!payment_method) {
return NextResponse.json(
{ success: false, error: "Método de pagamento é obrigatório" },
{ status: 400 }
{ status: 400 },
);
}
@@ -52,7 +51,7 @@ export async function POST(
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -60,7 +59,7 @@ export async function POST(
if (listing.seller_id === auth.userId) {
return NextResponse.json(
{ success: false, error: "Você não pode comprar seu próprio anúncio" },
{ status: 403 }
{ status: 403 },
);
}
@@ -68,15 +67,18 @@ export async function POST(
if (listing.listing_status !== "active") {
return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para compra" },
{ status: 400 }
{ status: 400 },
);
}
// Validar se é leilão (leilões não podem ser comprados diretamente, apenas com lances)
if (listing.is_auction) {
return NextResponse.json(
{ success: false, error: "Este item está em leilão. Use a funcionalidade de lance." },
{ status: 400 }
{
success: false,
error: "Este item está em leilão. Use a funcionalidade de lance.",
},
{ status: 400 },
);
}
@@ -99,7 +101,7 @@ export async function POST(
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -111,9 +113,10 @@ export async function POST(
return NextResponse.json(
{
success: false,
error: "Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).",
error:
"Ofertas personalizadas só são permitidas para métodos monetários (PIX, Tibia Coins ou Gold Coins).",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -122,14 +125,16 @@ export async function POST(
return NextResponse.json(
{
success: false,
error: "Valor da oferta personalizada deve ser numérico e maior que zero.",
error:
"Valor da oferta personalizada deve ser numérico e maior que zero.",
},
{ status: 400 }
{ status: 400 },
);
}
normalizedOfferCurrency =
custom_offer_currency && ["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency)
custom_offer_currency &&
["pix", "tibia_coins", "gold_coins"].includes(custom_offer_currency)
? custom_offer_currency
: normalizedMethod;
@@ -140,7 +145,10 @@ export async function POST(
}
// Verificar se já existe uma intenção pendente
const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId);
const existingIntention = await getPendingIntentionByBuyerAndListing(
listingId,
auth.userId,
);
if (existingIntention) {
// Se já existe e ainda pode trocar o método
if (existingIntention.payment_method_change_count < 1) {
@@ -148,23 +156,27 @@ export async function POST(
return NextResponse.json(
{
success: false,
error: "Você já possui uma intenção de compra pendente para este anúncio",
error:
"Você já possui uma intenção de compra pendente para este anúncio",
existing_intention_id: existingIntention.intention_id,
can_change_payment_method: true,
payment_method_change_count: existingIntention.payment_method_change_count,
message: "Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento",
payment_method_change_count:
existingIntention.payment_method_change_count,
message:
"Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento",
},
{ status: 400 }
{ status: 400 },
);
} else {
// Já atingiu o limite de trocas
return NextResponse.json(
{
success: false,
error: "Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento",
error:
"Você já possui uma intenção de compra pendente para este anúncio e atingiu o limite de trocas de método de pagamento",
existing_intention_id: existingIntention.intention_id,
},
{ status: 400 }
{ status: 400 },
);
}
}
@@ -201,7 +213,9 @@ export async function POST(
payment_method: normalizedMethod,
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
listing_price_tc: listing.price_tibia_coins || null,
listing_price_gp: listing.price_gold_coins ? Number(listing.price_gold_coins) : null,
listing_price_gp: listing.price_gold_coins
? Number(listing.price_gold_coins)
: null,
custom_offer_amount: normalizedOfferValue,
custom_offer_currency: normalizedOfferCurrency,
},
@@ -218,9 +232,10 @@ export async function POST(
{
success: true,
intention,
message: "Intenção de compra criada com sucesso. O vendedor será notificado.",
message:
"Intenção de compra criada com sucesso. O vendedor será notificado.",
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
secureLogger.error("❌ Erro ao criar intenção de compra", {
@@ -230,8 +245,7 @@ export async function POST(
return NextResponse.json(
{ success: false, error: "Erro ao criar intenção de compra" },
{ status: 500 }
{ status: 500 },
);
}
}
+58 -39
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth, optionalAuth } from "@/lib/auth/middleware";
import { type NextRequest, NextResponse } from "next/server";
import { optionalAuth, requireAuth } from "@/lib/auth/middleware";
import {
deleteListing,
getListingById,
updateListing,
deleteListing,
} from "@/lib/prisma/queries/listings";
import { hasPendingIntention } from "@/lib/prisma/queries/purchase-intentions";
import { secureLogger } from "@/lib/secure-logger";
import { updateListingSchema } from "@/lib/validation-schemas";
/**
@@ -15,20 +15,20 @@ import { updateListingSchema } from "@/lib/validation-schemas";
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const auth = await optionalAuth(request);
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -40,14 +40,13 @@ export async function GET(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
const hasPendingForUser =
auth && auth.userId
? await hasPendingIntention(listingId, auth.userId)
: false;
const hasPendingForUser = auth?.userId
? await hasPendingIntention(listingId, auth.userId)
: false;
return NextResponse.json(
{
@@ -57,7 +56,7 @@ export async function GET(
has_pending_intention_for_current_user: hasPendingForUser,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar anúncio", {
@@ -70,7 +69,7 @@ export async function GET(
success: false,
error: "Erro ao buscar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -81,7 +80,7 @@ export async function GET(
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -96,13 +95,13 @@ export async function PATCH(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -115,7 +114,7 @@ export async function PATCH(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -125,7 +124,7 @@ export async function PATCH(
success: false,
error: "Você não tem permissão para editar este anúncio",
},
{ status: 403 }
{ status: 403 },
);
}
@@ -146,7 +145,7 @@ export async function PATCH(
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -159,16 +158,37 @@ export async function PATCH(
price_tibia_coins?: number;
price_gold_coins?: number;
description?: string;
listing_status?: "active" | "sold" | "cancelled" | "in_escrow" | "expired";
listing_status?:
| "active"
| "sold"
| "cancelled"
| "in_escrow"
| "expired";
current_bid_amount?: number;
current_bidder_id?: number;
} = {
...(validatedData.quantity !== undefined && { quantity: validatedData.quantity }),
...(validatedData.price_pix !== null && validatedData.price_pix !== undefined && { price_pix: validatedData.price_pix }),
...(validatedData.price_tibia_coins !== null && validatedData.price_tibia_coins !== undefined && { price_tibia_coins: validatedData.price_tibia_coins }),
...(validatedData.price_gold_coins !== null && validatedData.price_gold_coins !== undefined && { price_gold_coins: validatedData.price_gold_coins }),
...(validatedData.description !== null && validatedData.description !== undefined && { description: validatedData.description }),
...(validatedData.listing_status !== undefined && { listing_status: validatedData.listing_status }),
...(validatedData.quantity !== undefined && {
quantity: validatedData.quantity,
}),
...(validatedData.price_pix !== null &&
validatedData.price_pix !== undefined && {
price_pix: validatedData.price_pix,
}),
...(validatedData.price_tibia_coins !== null &&
validatedData.price_tibia_coins !== undefined && {
price_tibia_coins: validatedData.price_tibia_coins,
}),
...(validatedData.price_gold_coins !== null &&
validatedData.price_gold_coins !== undefined && {
price_gold_coins: validatedData.price_gold_coins,
}),
...(validatedData.description !== null &&
validatedData.description !== undefined && {
description: validatedData.description,
}),
...(validatedData.listing_status !== undefined && {
listing_status: validatedData.listing_status,
}),
};
// Atualizar anúncio
@@ -180,7 +200,7 @@ export async function PATCH(
success: false,
error: "Erro ao atualizar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
@@ -195,7 +215,7 @@ export async function PATCH(
success: true,
listing: updatedListing,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao atualizar anúncio", {
@@ -208,7 +228,7 @@ export async function PATCH(
success: false,
error: "Erro ao atualizar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -219,7 +239,7 @@ export async function PATCH(
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Autenticação obrigatória
@@ -234,13 +254,13 @@ export async function DELETE(
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -253,7 +273,7 @@ export async function DELETE(
success: false,
error: "Anúncio não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -263,7 +283,7 @@ export async function DELETE(
success: false,
error: "Você não tem permissão para cancelar este anúncio",
},
{ status: 403 }
{ status: 403 },
);
}
@@ -276,7 +296,7 @@ export async function DELETE(
success: false,
error: "Erro ao cancelar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
@@ -291,7 +311,7 @@ export async function DELETE(
success: true,
message: "Anúncio cancelado com sucesso",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao cancelar anúncio", {
@@ -304,8 +324,7 @@ export async function DELETE(
success: false,
error: "Erro ao cancelar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
+14 -12
View File
@@ -1,23 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { getListingById } from "@/lib/prisma/queries/listings";
import { type NextRequest, NextResponse } from "next/server";
import { getMainCharacterByUserId } from "@/lib/prisma/queries/characters";
import { getListingById } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/listings/[id]/seller
* Retorna informações do vendedor (nome do personagem)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -25,12 +25,15 @@ export async function GET(
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
// Buscar personagem principal do vendedor no servidor do listing
const character = await getMainCharacterByUserId(listing.seller_id, listing.server_name);
const character = await getMainCharacterByUserId(
listing.seller_id,
listing.server_name,
);
return NextResponse.json(
{
@@ -41,7 +44,7 @@ export async function GET(
server_name: listing.server_name,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar informações do vendedor", {
@@ -51,8 +54,7 @@ export async function GET(
return NextResponse.json(
{ success: false, error: "Erro ao buscar informações do vendedor" },
{ status: 500 }
{ status: 500 },
);
}
}
+8 -9
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { incrementViews } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/listings/[id]/views
@@ -8,20 +8,20 @@ import { incrementViews } from "@/lib/prisma/queries/listings";
* Não requer autenticação (público)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const listingId = parseInt(id, 10);
if (isNaN(listingId)) {
if (Number.isNaN(listingId)) {
return NextResponse.json(
{
success: false,
error: "ID de anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -33,7 +33,7 @@ export async function POST(
success: true,
message: "View incrementada",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao incrementar views", {
@@ -46,8 +46,7 @@ export async function POST(
success: false,
error: "Erro ao incrementar views",
},
{ status: 500 }
{ status: 500 },
);
}
}
+5 -7
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getListings } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/listings/my
@@ -35,8 +35,7 @@ export async function GET(request: NextRequest) {
pageSize: searchParams.get("pageSize")
? parseInt(searchParams.get("pageSize")!, 10)
: 20,
sortBy: (searchParams.get("sortBy") ||
"created_at") as
sortBy: (searchParams.get("sortBy") || "created_at") as
| "created_at"
| "price_pix"
| "price_tibia_coins"
@@ -58,7 +57,7 @@ export async function GET(request: NextRequest) {
totalPages: Math.ceil(listingsData.total / filters.pageSize),
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar anúncios do usuário", {
@@ -71,8 +70,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Erro ao buscar anúncios",
},
{ status: 500 }
{ status: 500 },
);
}
}
+22 -22
View File
@@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { optionalAuth, requireAuth } from "@/lib/auth/middleware";
import {
createListing,
getActiveListings,
getListings,
} from "@/lib/prisma/queries/listings";
import { getPendingIntentionListingIdsForBuyer } from "@/lib/prisma/queries/purchase-intentions";
import { isServerActive } from "@/lib/prisma/queries/servers";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { createListingSchema } from "@/lib/validation-schemas";
import { requireAuth, optionalAuth } from "@/lib/auth/middleware";
import {
getListings,
createListing,
getActiveListings,
} from "@/lib/prisma/queries/listings";
import { isServerActive } from "@/lib/prisma/queries/servers";
import { getPendingIntentionListingIdsForBuyer } from "@/lib/prisma/queries/purchase-intentions";
/**
* GET /api/listings
@@ -52,8 +52,7 @@ export async function GET(request: NextRequest) {
pageSize: searchParams.get("pageSize")
? parseInt(searchParams.get("pageSize")!, 10)
: 20,
sortBy: (searchParams.get("sortBy") ||
"created_at") as
sortBy: (searchParams.get("sortBy") || "created_at") as
| "created_at"
| "price_pix"
| "price_tibia_coins"
@@ -73,13 +72,15 @@ export async function GET(request: NextRequest) {
const listingIds = listingsResponse.map((listing) => listing.listing_id);
const pendingListingIds = await getPendingIntentionListingIdsForBuyer(
auth.userId,
listingIds
listingIds,
);
const pendingSet = new Set(pendingListingIds);
listingsResponse = listingsResponse.map((listing) => ({
...listing,
has_pending_intention_for_current_user: pendingSet.has(listing.listing_id),
has_pending_intention_for_current_user: pendingSet.has(
listing.listing_id,
),
}));
}
@@ -94,7 +95,7 @@ export async function GET(request: NextRequest) {
totalPages: Math.ceil(listingsData.total / filters.pageSize),
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar anúncios", {
@@ -107,7 +108,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Erro ao buscar anúncios",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -158,7 +159,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -172,7 +173,7 @@ export async function POST(request: NextRequest) {
success: false,
error: `Servidor "${validatedData.server_name}" não existe ou não está ativo`,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -181,7 +182,7 @@ export async function POST(request: NextRequest) {
// Mas a interface CreateListingData exige item_id, então usamos item_id_unified como fallback
// O refine do schema garante que pelo menos um está presente, então o fallback nunca será usado
const itemId = validatedData.item_id ?? validatedData.item_id_unified!;
const listing = await createListing({
seller_id: auth.userId,
item_id: itemId,
@@ -210,7 +211,7 @@ export async function POST(request: NextRequest) {
success: true,
listing,
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
secureLogger.error("❌ Erro ao criar anúncio", {
@@ -223,8 +224,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Erro ao criar anúncio",
},
{ status: 500 }
{ status: 500 },
);
}
}
+8 -9
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { markAsRead } from "@/lib/prisma/queries/notifications";
import { secureLogger } from "@/lib/secure-logger";
/**
* POST /api/notifications/[id]/read
@@ -9,7 +9,7 @@ import { markAsRead } from "@/lib/prisma/queries/notifications";
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -18,10 +18,10 @@ export async function POST(
const { id } = await params;
const notificationId = parseInt(id, 10);
if (isNaN(notificationId)) {
if (Number.isNaN(notificationId)) {
return NextResponse.json(
{ success: false, error: "ID de notificação inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -30,7 +30,7 @@ export async function POST(
if (!success) {
return NextResponse.json(
{ success: false, error: "Notificação não encontrada ou já está lida" },
{ status: 404 }
{ status: 404 },
);
}
@@ -39,7 +39,7 @@ export async function POST(
success: true,
message: "Notificação marcada como lida",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar notificação como lida", {
@@ -48,8 +48,7 @@ export async function POST(
return NextResponse.json(
{ success: false, error: "Erro ao marcar notificação como lida" },
{ status: 500 }
{ status: 500 },
);
}
}
+10 -11
View File
@@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import {
getUserNotifications,
getUnreadCount,
markAsRead,
getUserNotifications,
markAllAsRead,
} from "@/lib/prisma/queries/notifications";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/notifications
@@ -25,7 +24,8 @@ export async function GET(request: NextRequest) {
: 50;
const notifications = await getUserNotifications(auth.userId, {
is_read: isRead === "true" ? true : isRead === "false" ? false : undefined,
is_read:
isRead === "true" ? true : isRead === "false" ? false : undefined,
limit,
});
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
notifications,
unread_count: unreadCount,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar notificações", {
@@ -47,7 +47,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao buscar notificações" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -73,13 +73,13 @@ export async function POST(request: NextRequest) {
message: `${count} notificações marcadas como lidas`,
count,
},
{ status: 200 }
{ status: 200 },
);
}
return NextResponse.json(
{ success: false, error: "Ação inválida" },
{ status: 400 }
{ status: 400 },
);
} catch (error) {
secureLogger.error("❌ Erro ao marcar notificações como lidas", {
@@ -88,8 +88,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao marcar notificações como lidas" },
{ status: 500 }
{ status: 500 },
);
}
}
+4 -5
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getUnreadCount } from "@/lib/prisma/queries/notifications";
@@ -18,13 +18,12 @@ export async function GET(request: NextRequest) {
success: true,
count,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
} catch (_error) {
return NextResponse.json(
{ success: false, error: "Erro ao buscar contagem de notificações" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -2,14 +2,14 @@
* PATCH /api/purchase-intentions/[intentionId]/payment-method
* Permite ao comprador trocar o método de pagamento (máximo 1 vez)
*/
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { createSystemMessageInChat } from "@/lib/chat/system-messages";
import { getListingById } from "@/lib/prisma/queries/listings";
import {
getPurchaseIntentionById,
updatePurchaseIntentionPaymentMethod,
} from "@/lib/prisma/queries/purchase-intentions";
import { getListingById } from "@/lib/prisma/queries/listings";
import { createSystemMessageInChat } from "@/lib/chat/system-messages";
import { secureLogger } from "@/lib/secure-logger";
function getPaymentMethodLabel(method: string): string {
@@ -29,7 +29,7 @@ function getPaymentMethodLabel(method: string): string {
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ intentionId: string }> }
{ params }: { params: Promise<{ intentionId: string }> },
) {
try {
const authResult = await requireAuth(request);
@@ -38,21 +38,24 @@ export async function PATCH(
const { intentionId: intentionIdParam } = await params;
const intentionId = parseInt(intentionIdParam, 10);
if (isNaN(intentionId)) {
if (Number.isNaN(intentionId)) {
return NextResponse.json(
{ success: false, error: "ID de intenção inválido" },
{ status: 400 }
{ status: 400 },
);
}
const body = await request.json();
const { payment_method } = body;
if (!payment_method || !["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)) {
if (
!payment_method ||
!["pix", "tibia_coins", "gold_coins", "trade"].includes(payment_method)
) {
return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -61,39 +64,53 @@ export async function PATCH(
if (!intention) {
return NextResponse.json(
{ success: false, error: "Intenção não encontrada" },
{ status: 404 }
{ status: 404 },
);
}
// Verificar se o usuário é o comprador
if (intention.buyer_id !== auth.userId) {
return NextResponse.json(
{ success: false, error: "Apenas o comprador pode trocar o método de pagamento" },
{ status: 403 }
{
success: false,
error: "Apenas o comprador pode trocar o método de pagamento",
},
{ status: 403 },
);
}
// Verificar se a intenção está pendente
if (intention.status !== "pending") {
return NextResponse.json(
{ success: false, error: "Apenas intenções pendentes podem ter o método de pagamento alterado" },
{ status: 400 }
{
success: false,
error:
"Apenas intenções pendentes podem ter o método de pagamento alterado",
},
{ status: 400 },
);
}
// Verificar se já atingiu o limite de trocas
if (intention.payment_method_change_count >= 1) {
return NextResponse.json(
{ success: false, error: "Você já atingiu o limite de trocas de método de pagamento (1 vez)" },
{ status: 400 }
{
success: false,
error:
"Você já atingiu o limite de trocas de método de pagamento (1 vez)",
},
{ status: 400 },
);
}
// Verificar se o novo método é diferente
if (intention.payment_method === payment_method) {
return NextResponse.json(
{ success: false, error: "O novo método de pagamento deve ser diferente do atual" },
{ status: 400 }
{
success: false,
error: "O novo método de pagamento deve ser diferente do atual",
},
{ status: 400 },
);
}
@@ -102,7 +119,7 @@ export async function PATCH(
if (!listing) {
return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -120,7 +137,7 @@ export async function PATCH(
error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -128,7 +145,7 @@ export async function PATCH(
const oldPaymentMethod = intention.payment_method;
const updatedIntention = await updatePurchaseIntentionPaymentMethod(
intentionId,
payment_method as any
payment_method as any,
);
// Criar mensagem do sistema no chat
@@ -156,9 +173,10 @@ export async function PATCH(
{
success: true,
intention: updatedIntention,
message: "Método de pagamento atualizado com sucesso. O vendedor foi notificado.",
message:
"Método de pagamento atualizado com sucesso. O vendedor foi notificado.",
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao atualizar método de pagamento", {
@@ -167,9 +185,14 @@ export async function PATCH(
});
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Erro ao atualizar método de pagamento" },
{ status: 500 }
{
success: false,
error:
error instanceof Error
? error.message
: "Erro ao atualizar método de pagamento",
},
{ status: 500 },
);
}
}
+13 -13
View File
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getPurchaseIntentionsByBuyer } from "@/lib/prisma/queries/purchase-intentions";
import { prisma } from "@/lib/prisma/app";
import { getPurchaseIntentionsByBuyer } from "@/lib/prisma/queries/purchase-intentions";
import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/**
* GET /api/purchase-intentions/my
@@ -78,9 +78,7 @@ export async function GET(request: NextRequest) {
// Buscar character names dos vendedores em batch
const sellerServerPairs = [
...new Set(
listings.map((l) => `${l.seller_id}:${l.server_name}`)
),
...new Set(listings.map((l) => `${l.seller_id}:${l.server_name}`)),
];
const characterMap = new Map<string, string | null>();
@@ -98,7 +96,7 @@ export async function GET(request: NextRequest) {
},
});
characterMap.set(pair, character?.character_name || null);
})
}),
);
// Serializar datas para string
@@ -123,8 +121,10 @@ export async function GET(request: NextRequest) {
return {
...intention,
created_at: serializeDate(intention.created_at as Date) || intention.created_at,
updated_at: serializeDate(intention.updated_at as Date) || intention.updated_at,
created_at:
serializeDate(intention.created_at as Date) || intention.created_at,
updated_at:
serializeDate(intention.updated_at as Date) || intention.updated_at,
expires_at: serializeDate(intention.expires_at as Date | null),
accepted_at: serializeDate(intention.accepted_at as Date | null),
rejected_at: serializeDate(intention.rejected_at as Date | null),
@@ -140,7 +140,8 @@ export async function GET(request: NextRequest) {
seller_id: listing.seller_id,
price_pix: listing.price_pix ? Number(listing.price_pix) : null,
price_tibia_coins:
listing.price_tibia_coins !== null && listing.price_tibia_coins !== undefined
listing.price_tibia_coins !== null &&
listing.price_tibia_coins !== undefined
? Number(listing.price_tibia_coins)
: null,
price_gold_coins: listing.price_gold_coins
@@ -185,7 +186,7 @@ export async function GET(request: NextRequest) {
intentions: intentionsWithListingDetails,
count: intentionsWithListingDetails.length,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar intenções de compra do usuário", {
@@ -195,8 +196,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{ success: false, error: "Erro ao buscar intenções de compra" },
{ status: 500 }
{ status: 500 },
);
}
}
+4 -5
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { getActiveServers } from "@/lib/prisma/queries/servers";
import { secureLogger } from "@/lib/secure-logger";
@@ -6,7 +6,7 @@ import { secureLogger } from "@/lib/secure-logger";
* GET /api/servers
* Lista todos os servidores ativos disponíveis
*/
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
try {
const servers = await getActiveServers();
@@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
},
})),
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar servidores", {
@@ -43,8 +43,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Erro ao buscar servidores",
},
{ status: 500 }
{ status: 500 },
);
}
}
-1
View File
@@ -13,4 +13,3 @@ export async function GET() {
message: "Socket.io está ativo",
});
}
+23 -23
View File
@@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
countUsersInSubscriptionTier,
deleteSubscriptionTier,
getSubscriptionTierById,
updateSubscriptionTier,
deleteSubscriptionTier,
countUsersInSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers";
import { updateSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger";
import { updateSubscriptionTierSchema } from "@/lib/validation-schemas";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
@@ -25,7 +25,7 @@ export async function GET(
if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json(
{ success: false, error: "ID de tier inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -34,7 +34,7 @@ export async function GET(
if (!tier) {
return NextResponse.json(
{ success: false, error: "Plano não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -43,7 +43,7 @@ export async function GET(
success: true,
tier,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao consultar subscription tier", {
@@ -55,14 +55,14 @@ export async function GET(
success: false,
error: "Erro ao consultar plano de assinatura",
},
{ status: 500 }
{ status: 500 },
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
@@ -76,7 +76,7 @@ export async function PATCH(
if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json(
{ success: false, error: "ID de tier inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -105,7 +105,7 @@ export async function PATCH(
success: true,
tier: updatedTier,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
if (error instanceof Error && "issues" in error) {
@@ -115,7 +115,7 @@ export async function PATCH(
error: "Dados inválidos",
details: (error as any).issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -128,14 +128,14 @@ export async function PATCH(
success: false,
error: "Erro ao atualizar plano de assinatura",
},
{ status: 500 }
{ status: 500 },
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
@@ -149,7 +149,7 @@ export async function DELETE(
if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json(
{ success: false, error: "ID de tier inválido" },
{ status: 400 }
{ status: 400 },
);
}
@@ -158,7 +158,7 @@ export async function DELETE(
if (!tier) {
return NextResponse.json(
{ success: false, error: "Plano não encontrado" },
{ status: 404 }
{ status: 404 },
);
}
@@ -168,7 +168,7 @@ export async function DELETE(
success: false,
error: "O plano padrão 'free' não pode ser removido.",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -178,10 +178,11 @@ export async function DELETE(
return NextResponse.json(
{
success: false,
error: "Plano não pode ser removido enquanto houver usuários associados.",
error:
"Plano não pode ser removido enquanto houver usuários associados.",
users: userCount,
},
{ status: 409 }
{ status: 409 },
);
}
@@ -191,7 +192,7 @@ export async function DELETE(
{
success: true,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao remover subscription tier", {
@@ -203,8 +204,7 @@ export async function DELETE(
success: false,
error: "Erro ao remover plano de assinatura",
},
{ status: 500 }
{ status: 500 },
);
}
}
+22 -22
View File
@@ -1,14 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
assignUserToSubscriptionTier,
getDefaultSubscriptionTier,
getSubscriptionTierById,
getSubscriptionTierBySlug,
getDefaultSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers";
import { assignSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger";
import { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
import { assignSubscriptionTierSchema } from "@/lib/validation-schemas";
export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request);
@@ -27,19 +27,17 @@ export async function POST(request: NextRequest) {
success: false,
error: "Usuário não encontrado.",
},
{ status: 404 }
{ status: 404 },
);
}
let targetTierId: number | null = null;
let targetTier:
| {
subscription_tier_id: number;
name: string;
slug: string;
level: number;
}
| null = null;
let targetTier: {
subscription_tier_id: number;
name: string;
slug: string;
level: number;
} | null = null;
if (parsed.tierId !== undefined) {
if (parsed.tierId === null) {
@@ -50,7 +48,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Plano padrão não encontrado.",
},
{ status: 500 }
{ status: 500 },
);
}
targetTier = defaultTier;
@@ -62,20 +60,23 @@ export async function POST(request: NextRequest) {
success: false,
error: "Plano informado não existe.",
},
{ status: 404 }
{ status: 404 },
);
}
targetTier = tierById;
}
} else if (parsed.tierSlug) {
const tierBySlug = await getSubscriptionTierBySlug(parsed.tierSlug, false);
const tierBySlug = await getSubscriptionTierBySlug(
parsed.tierSlug,
false,
);
if (!tierBySlug) {
return NextResponse.json(
{
success: false,
error: "Plano informado não existe.",
},
{ status: 404 }
{ status: 404 },
);
}
targetTier = tierBySlug;
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Plano de assinatura não identificado.",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -106,7 +107,7 @@ export async function POST(request: NextRequest) {
level: targetTier.level,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
if (error instanceof Error && "issues" in error) {
@@ -116,7 +117,7 @@ export async function POST(request: NextRequest) {
error: "Dados inválidos",
details: (error as any).issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -129,8 +130,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Erro ao atribuir plano de assinatura ao usuário",
},
{ status: 500 }
{ status: 500 },
);
}
}
+8 -9
View File
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
getAllSubscriptionTiers,
createSubscriptionTier,
getAllSubscriptionTiers,
} from "@/lib/prisma/queries/subscription-tiers";
import { createSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger";
import { createSubscriptionTierSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) {
const auth = await requireAdminUser(request);
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
success: true,
tiers,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao listar subscription tiers", {
@@ -32,7 +32,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Erro ao listar planos de assinatura",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -68,7 +68,7 @@ export async function POST(request: NextRequest) {
success: true,
tier,
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
if (error instanceof NextResponse) {
@@ -82,7 +82,7 @@ export async function POST(request: NextRequest) {
error: "Dados inválidos",
details: (error as any).issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -95,8 +95,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Erro ao criar plano de assinatura",
},
{ status: 500 }
{ status: 500 },
);
}
}
+28 -27
View File
@@ -1,21 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { type NextRequest, NextResponse } from "next/server";
import { optionalAuth } from "@/lib/auth/middleware";
import {
getAdAnalytics,
getMarketplaceStats,
trackEvent,
trackEventsBatch,
} from "@/lib/prisma/queries/telemetry";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import {
telemetryEventSchema,
telemetryBatchSchema,
telemetryEventSchema,
} from "@/lib/validation-schemas";
import { optionalAuth } from "@/lib/auth/middleware";
import {
trackEvent,
trackEventsBatch,
getAdAnalytics,
getMarketplaceStats,
} from "@/lib/prisma/queries/telemetry";
/**
* POST /api/telemetry/event
@@ -33,12 +33,14 @@ export async function POST(request: NextRequest) {
// Autenticação opcional (usuários anônimos podem gerar eventos)
const authResult = await optionalAuth(request);
const userId = authResult instanceof NextResponse ? undefined : authResult?.userId;
const userId =
authResult instanceof NextResponse ? undefined : authResult?.userId;
const body = await request.json();
// Verificar se é batch ou evento único
const isBatch = body.events && Array.isArray(body.events) && body.events.length > 0;
const isBatch =
body.events && Array.isArray(body.events) && body.events.length > 0;
if (isBatch) {
// Batch de eventos
@@ -50,7 +52,7 @@ export async function POST(request: NextRequest) {
metadata: event.metadata || {},
})),
};
const validationResult = telemetryBatchSchema.safeParse(normalizedBody);
if (!validationResult.success) {
@@ -60,7 +62,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: validationResult.error.issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
success: true,
events: trackedEvents,
},
{ status: 201 }
{ status: 201 },
);
} else {
// Evento único
@@ -97,7 +99,7 @@ export async function POST(request: NextRequest) {
...body,
metadata: body.metadata || {},
};
const validationResult = telemetryEventSchema.safeParse(normalizedBody);
if (!validationResult.success) {
@@ -107,7 +109,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: validationResult.error.issues,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -134,7 +136,7 @@ export async function POST(request: NextRequest) {
success: true,
event: trackedEvent,
},
{ status: 201 }
{ status: 201 },
);
}
} catch (error) {
@@ -148,7 +150,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Erro ao registrar evento",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -178,7 +180,7 @@ export async function GET(request: NextRequest) {
success: true,
stats,
},
{ status: 200 }
{ status: 200 },
);
}
@@ -186,13 +188,13 @@ export async function GET(request: NextRequest) {
if (adIdParam) {
const adId = parseInt(adIdParam, 10);
if (isNaN(adId)) {
if (Number.isNaN(adId)) {
return NextResponse.json(
{
success: false,
error: "ID do anúncio inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -204,7 +206,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Analytics não encontrado para este anúncio",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -213,7 +215,7 @@ export async function GET(request: NextRequest) {
success: true,
analytics,
},
{ status: 200 }
{ status: 200 },
);
}
@@ -222,7 +224,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Parâmetro 'adId' ou 'dashboard' é obrigatório",
},
{ status: 400 }
{ status: 400 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar analytics", {
@@ -235,8 +237,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Erro ao buscar analytics",
},
{ status: 500 }
{ status: 500 },
);
}
}
+9 -10
View File
@@ -1,22 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
if (Number.isNaN(userId)) {
return NextResponse.json(
{
success: false,
error: "ID de usuário inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -28,7 +28,7 @@ export async function GET(
success: false,
error: "Usuário não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -46,7 +46,7 @@ export async function GET(
createdAt: user.created_at,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
secureLogger.error("❌ Erro ao buscar perfil público", {
@@ -60,8 +60,7 @@ export async function GET(
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+27 -39
View File
@@ -1,37 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import {
updateCharacter,
deleteCharacter,
getCharacterById,
updateCharacter,
} from "@/lib/prisma/queries/characters";
import { secureLogger } from "@/lib/secure-logger";
import { characterSchema } from "@/lib/validation-schemas";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const characterId = parseInt(id, 10);
if (isNaN(characterId)) {
if (Number.isNaN(characterId)) {
return NextResponse.json(
{
success: false,
error: "ID de personagem inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -52,22 +51,18 @@ export async function PUT(
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
const validatedData = validationResult.data;
// Atualizar personagem (verifica ownership internamente)
const updatedCharacter = await updateCharacter(
characterId,
auth.userId,
{
character_name: validatedData.characterName,
server_name: validatedData.serverName,
is_main: validatedData.isMain,
}
);
const updatedCharacter = await updateCharacter(characterId, auth.userId, {
character_name: validatedData.characterName,
server_name: validatedData.serverName,
is_main: validatedData.isMain,
});
secureLogger.info("✅ Personagem atualizado com sucesso", {
userId: auth.userId,
@@ -87,7 +82,7 @@ export async function PUT(
createdAt: updatedCharacter.created_at,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -96,16 +91,13 @@ export async function PUT(
}
// Verificar se é erro de permissão
if (
error instanceof Error &&
error.message.includes("não tem permissão")
) {
if (error instanceof Error && error.message.includes("não tem permissão")) {
return NextResponse.json(
{
success: false,
error: error.message,
},
{ status: 403 }
{ status: 403 },
);
}
@@ -120,35 +112,35 @@ export async function PUT(
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const { id } = await params;
const characterId = parseInt(id, 10);
if (isNaN(characterId)) {
if (Number.isNaN(characterId)) {
return NextResponse.json(
{
success: false,
error: "ID de personagem inválido",
},
{ status: 400 }
{ status: 400 },
);
}
@@ -165,7 +157,7 @@ export async function DELETE(
{
success: true,
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -174,16 +166,13 @@ export async function DELETE(
}
// Verificar se é erro de permissão
if (
error instanceof Error &&
error.message.includes("não tem permissão")
) {
if (error instanceof Error && error.message.includes("não tem permissão")) {
return NextResponse.json(
{
success: false,
error: error.message,
},
{ status: 403 }
{ status: 403 },
);
}
@@ -198,8 +187,7 @@ export async function DELETE(
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+16 -14
View File
@@ -1,18 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserCharacters, createCharacter } from "@/lib/prisma/queries/characters";
import {
createCharacter,
getUserCharacters,
} from "@/lib/prisma/queries/characters";
import { secureLogger } from "@/lib/secure-logger";
import { characterSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
// Listar personagens do usuário
@@ -30,7 +33,7 @@ export async function GET(request: NextRequest) {
createdAt: char.created_at,
})),
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -49,7 +52,7 @@ export async function GET(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -58,11 +61,11 @@ export async function POST(request: NextRequest) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const body = await request.json();
@@ -82,7 +85,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -113,7 +116,7 @@ export async function POST(request: NextRequest) {
createdAt: newCharacter.created_at,
},
},
{ status: 201 }
{ status: 201 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -128,7 +131,7 @@ export async function POST(request: NextRequest) {
success: false,
error: "Este personagem já está cadastrado neste servidor",
},
{ status: 409 }
{ status: 409 },
);
}
@@ -143,8 +146,7 @@ export async function POST(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+13 -14
View File
@@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { type NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserById, updateUser } from "@/lib/prisma/queries/users";
import { getUserCharacters } from "@/lib/prisma/queries/characters";
import { getUserById, updateUser } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
import { updateUserSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
// Buscar dados do usuário
@@ -25,7 +25,7 @@ export async function GET(request: NextRequest) {
success: false,
error: "Usuário não encontrado",
},
{ status: 404 }
{ status: 404 },
);
}
@@ -61,7 +61,7 @@ export async function GET(request: NextRequest) {
createdAt: char.created_at,
})),
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -80,7 +80,7 @@ export async function GET(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
@@ -89,11 +89,11 @@ export async function PUT(request: NextRequest) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
const body = await request.json();
@@ -113,7 +113,7 @@ export async function PUT(request: NextRequest) {
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
{ status: 400 },
);
}
@@ -153,7 +153,7 @@ export async function PUT(request: NextRequest) {
isVerified: updatedUser.is_verified,
},
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
@@ -172,8 +172,7 @@ export async function PUT(request: NextRequest) {
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
{ status: 500 },
);
}
}
+6 -7
View File
@@ -1,10 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { type NextRequest, NextResponse } from "next/server";
import { markAsPaid } from "@/lib/payment-cache";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
@@ -56,11 +55,11 @@ export async function POST(request: NextRequest) {
// Rejeitar eventos com mais de 5 minutos de diferença
if (timeDiff > 5 * 60 * 1000) {
secureLogger.warn("Webhook rejeitado: timestamp inválido", {
timeDiff: timeDiff / 1000 + "s",
timeDiff: `${timeDiff / 1000}s`,
});
return NextResponse.json(
{ error: "Timestamp inválido" },
{ status: 400 }
{ status: 400 },
);
}
}
@@ -119,7 +118,7 @@ export async function POST(request: NextRequest) {
secureLogger.info(
"Registro de pagamento criado",
secureLogger.mask(donationRecord)
secureLogger.mask(donationRecord),
);
// Salvar no cache para frontend detectar via polling
@@ -150,7 +149,7 @@ export async function POST(request: NextRequest) {
secureLogger.error("Webhook error", error);
return NextResponse.json(
{ error: "Erro ao processar webhook" },
{ status: 500 }
{ status: 500 },
);
}
}
+11 -5
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Nunito_Sans, Geist_Mono } from "next/font/google";
import { Geist_Mono, Nunito_Sans } from "next/font/google";
import "./globals.css";
const nunitoSans = Nunito_Sans({
@@ -15,7 +15,8 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Tibia Trade",
description: "Marketplace seguro para negociação de itens e serviços de Tibia.",
description:
"Marketplace seguro para negociação de itens e serviços de Tibia.",
};
export const viewport: Viewport = {
@@ -24,13 +25,18 @@ export const viewport: Viewport = {
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pt" suppressHydrationWarning>
<body className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}>
<body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { MetadataRoute } from "next";
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://tibiatrade.evandro.dev.br";
+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>
);
}
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more