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 type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Header from "@/components/Header";
import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePage"; import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePage";
import { getSession } from "@/lib/redis/session"; import Header from "@/components/Header";
import { getUserById } from "@/lib/prisma/queries/users";
import { searchAdminItems } from "@/lib/prisma/queries/items"; import { searchAdminItems } from "@/lib/prisma/queries/items";
import { getActiveServers } from "@/lib/prisma/queries/servers"; 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> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@@ -57,4 +57,3 @@ export default async function AdminNewItemPage({
</div> </div>
); );
} }
+24 -11
View File
@@ -1,12 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs";
import Header from "@/components/Header"; 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 { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers";
import { getUserById } from "@/lib/prisma/queries/users"; import { getUserById } from "@/lib/prisma/queries/users";
import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs"; import { getSession } from "@/lib/redis/session";
import { searchAdminItems } from "@/lib/prisma/queries/items";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@@ -30,7 +30,8 @@ export default async function AdminPage({
searchParams instanceof Promise ? await searchParams : searchParams; searchParams instanceof Promise ? await searchParams : searchParams;
const tabParam = resolvedSearchParams?.tab; const tabParam = resolvedSearchParams?.tab;
const initialTab = const initialTab =
typeof tabParam === "string" && ["overview", "subscriptions", "items"].includes(tabParam) typeof tabParam === "string" &&
["overview", "subscriptions", "items"].includes(tabParam)
? (tabParam as "overview" | "subscriptions" | "items") ? (tabParam as "overview" | "subscriptions" | "items")
: "overview"; : "overview";
@@ -58,14 +59,24 @@ export default async function AdminPage({
...tier, ...tier,
price_monthly: tier.price_monthly ? Number(tier.price_monthly) : null, price_monthly: tier.price_monthly ? Number(tier.price_monthly) : null,
price_yearly: tier.price_yearly ? Number(tier.price_yearly) : 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, created_at:
updated_at: tier.updated_at instanceof Date ? tier.updated_at.toISOString() : tier.updated_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) => ({ features: featureList.map((feature) => ({
...feature, ...feature,
created_at: 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: 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 /> <Header />
<main className="container mx-auto px-4 py-10 space-y-8"> <main className="container mx-auto px-4 py-10 space-y-8">
<section className="space-y-2"> <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"> <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> </p>
</section> </section>
@@ -92,4 +106,3 @@ export default async function AdminPage({
</div> </div>
); );
} }
+1 -3
View File
@@ -4,8 +4,7 @@ import { redirect } from "next/navigation";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
title: "Assinaturas e Permissões", title: "Assinaturas e Permissões",
description: description: "Redirecionando para o painel administrativo centralizado.",
"Redirecionando para o painel administrativo centralizado.",
}; };
} }
@@ -17,4 +16,3 @@ export default async function AdminSubscriptionsRedirect({
const { locale } = await params; const { locale } = await params;
redirect(`/${locale}/admin?tab=subscriptions`); redirect(`/${locale}/admin?tab=subscriptions`);
} }
+6 -6
View File
@@ -1,13 +1,13 @@
"use client"; "use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import Header from "@/components/Header"; import { Heart, Home, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BlurFade } from "@/components/ui/blur-fade";
import { Heart, Share2, Home } from "lucide-react";
import Link from "next/link"; 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() { export default function ThankYouPage() {
const params = useParams(); 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 { DonationCard } from "@/components/donation-card";
import { BlurFade } from "@/components/ui/blur-fade"; import Header from "@/components/Header";
import { NumberTicker } from "@/components/ui/number-ticker"; import { ShareButton } from "@/components/share-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Heart } from "lucide-react"; import { Badge } from "@/components/ui/badge";
import { getTranslations } from "next-intl/server"; import { BlurFade } from "@/components/ui/blur-fade";
import Link from "next/link"; import { Button } from "@/components/ui/button";
import { ShareButton } from "@/components/share-button"; import { NumberTicker } from "@/components/ui/number-ticker";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -60,7 +59,7 @@ export default async function SupportPage({
}; };
// Custos mensais REAIS // Custos mensais REAIS
const monthlyCosts = { const _monthlyCosts = {
vps: 69, // R$ 69/mês vps: 69, // R$ 69/mês
devHours: 40, // média horas/mês devHours: 40, // média horas/mês
devRate: 50, // R$ 50/hora devRate: 50, // R$ 50/hora
+13 -4
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server"; import { getMessages, setRequestLocale } from "next-intl/server";
import Script from "next/script";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { QueryProvider } from "@/lib/providers/query-provider"; import { QueryProvider } from "@/lib/providers/query-provider";
@@ -113,14 +113,19 @@ export default async function LocaleLayout({
<QueryProvider> <QueryProvider>
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<ThemeProvider attribute="class" defaultTheme="dark"> <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({ {JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
name: "Tibia Trade", name: "Tibia Trade",
url: baseUrl, url: baseUrl,
logo: `${baseUrl}/logo.png`, 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"], sameAs: ["https://discord.gg/tibiatrade"],
contactPoint: { contactPoint: {
"@type": "ContactPoint", "@type": "ContactPoint",
@@ -129,7 +134,11 @@ export default async function LocaleLayout({
}, },
})} })}
</Script> </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({ {JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
+88 -51
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { MarketplaceFilters } from "@/components/marketplace-filters";
import { ItemCard } from "@/components/item-card"; import { ItemCard } from "@/components/item-card";
import { MarketplaceFilters } from "@/components/marketplace-filters";
interface Item { interface Item {
id: string | number; id: string | number;
@@ -38,7 +38,9 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [rarities, setRarities] = useState<string[]>([]); const [rarities, setRarities] = useState<string[]>([]);
const [sort, setSort] = useState<string>("name"); 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 [vocation, setVocation] = useState<string | null>(null);
const debounceRef = useRef<number | undefined>(undefined); const debounceRef = useRef<number | undefined>(undefined);
@@ -77,7 +79,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (Array.isArray(data.categories)) { 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); setCategoryOptions(opts);
} }
} catch { } catch {
@@ -90,7 +95,12 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
setPage(1); setPage(1);
setQuery(q); 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); setPage(1);
setCategories(f.category || []); setCategories(f.category || []);
setRarities(f.rarity || []); setRarities(f.rarity || []);
@@ -103,7 +113,14 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return ( return (
<div className="space-y-8"> <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"> <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) => { {items.map((item) => {
@@ -142,7 +159,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return map[v] || v || "other"; 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 category = normalizeCategory(item.category ?? categoryPt);
const name = item.name ?? anyItem.nome ?? String(item.id); const name = item.name ?? anyItem.nome ?? String(item.id);
const image = item.image ?? anyItem.image_url ?? ""; const image = item.image ?? anyItem.image_url ?? "";
@@ -152,39 +172,39 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
typeof item.level === "number" typeof item.level === "number"
? item.level ? item.level
: typeof anyItem.nivel === "number" : typeof anyItem.nivel === "number"
? anyItem.nivel ? anyItem.nivel
: typeof anyItem.lvl === "number" : typeof anyItem.lvl === "number"
? anyItem.lvl ? anyItem.lvl
: undefined; : undefined;
const weight: number | undefined = const weight: number | undefined =
typeof item.weight === "number" typeof item.weight === "number"
? item.weight ? item.weight
: typeof anyItem.peso === "number" : typeof anyItem.peso === "number"
? anyItem.peso ? anyItem.peso
: typeof anyItem.peso_em_oz === "number" : typeof anyItem.peso_em_oz === "number"
? anyItem.peso_em_oz ? anyItem.peso_em_oz
: undefined; : undefined;
const slots: number | undefined = const slots: number | undefined =
typeof item.slots === "number" typeof item.slots === "number"
? item.slots ? item.slots
: typeof anyItem.slots === "number" : typeof anyItem.slots === "number"
? anyItem.slots ? anyItem.slots
: undefined; : undefined;
const attack: number | undefined = const attack: number | undefined =
typeof anyItem.ataque === "number" typeof anyItem.ataque === "number"
? anyItem.ataque ? anyItem.ataque
: typeof anyItem.atq === "number" : typeof anyItem.atq === "number"
? anyItem.atq ? anyItem.atq
: undefined; : undefined;
const defense: number | undefined = const defense: number | undefined =
typeof anyItem.defesa === "number" typeof anyItem.defesa === "number"
? anyItem.defesa ? anyItem.defesa
: typeof anyItem.def === "number" : typeof anyItem.def === "number"
? anyItem.def ? anyItem.def
: undefined; : undefined;
const armor: number | undefined = const armor: number | undefined =
typeof anyItem.arm === "number" ? anyItem.arm : undefined; typeof anyItem.arm === "number" ? anyItem.arm : undefined;
@@ -192,9 +212,10 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
const skill: string | undefined = const skill: string | undefined =
typeof item.skill === "string" && item.skill !== "Nenhum." typeof item.skill === "string" && item.skill !== "Nenhum."
? item.skill ? item.skill
: typeof anyItem.skill_para_usar === "string" && anyItem.skill_para_usar !== "Nenhum." : typeof anyItem.skill_para_usar === "string" &&
? anyItem.skill_para_usar anyItem.skill_para_usar !== "Nenhum."
: undefined; ? anyItem.skill_para_usar
: undefined;
const vocation: string | undefined = const vocation: string | undefined =
typeof anyItem.voc === "string" && anyItem.voc typeof anyItem.voc === "string" && anyItem.voc
? anyItem.voc ? anyItem.voc
@@ -203,31 +224,45 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
return ( return (
<ItemCard <ItemCard
key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`} key={`${String((anyItem as any).category)}::${String(item.id)}::${name}`}
item={{ item={
id: String(item.id), {
name, id: String(item.id),
image, name,
category: category as any, image,
rarity: (item.rarity as any) ?? "common", category: category as any,
categoryPt, rarity: (item.rarity as any) ?? "common",
stats: { categoryPt,
weight, stats: {
slots, weight,
attack, slots,
defense, attack,
armor, defense,
} as any, armor,
requirements: { } as any,
level, requirements: {
skill, level,
vocation, skill,
} as any, vocation,
// Campos específicos de aljavas (e reutilizáveis para outras categorias) } as any,
volume: typeof anyItem.volume === "number" ? anyItem.volume : undefined, // Campos específicos de aljavas (e reutilizáveis para outras categorias)
bonus_de_skill: typeof anyItem.bonus_de_skill === "number" ? anyItem.bonus_de_skill : undefined, volume:
protecao: typeof anyItem.protecao === "string" ? anyItem.protecao : undefined, typeof anyItem.volume === "number"
dropa_de: typeof anyItem.dropa_de === "string" ? anyItem.dropa_de : undefined, ? anyItem.volume
} as any} : 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" mode="catalog"
/> />
); );
@@ -236,14 +271,18 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
type="button"
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50" className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Anterior Anterior
</button> </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 <button
type="button"
className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50" className="px-3 py-1 rounded border text-sm hover:bg-muted disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
@@ -254,5 +293,3 @@ export default function LibraryClient({ initial }: { initial: InitialData }) {
</div> </div>
); );
} }
+17 -9
View File
@@ -1,5 +1,5 @@
import Header from "@/components/Header";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import Header from "@/components/Header";
import LibraryClient from "./LibraryClient"; import LibraryClient from "./LibraryClient";
interface Item { interface Item {
@@ -18,17 +18,24 @@ interface Item {
async function fetchItems(params: URLSearchParams) { async function fetchItems(params: URLSearchParams) {
const qs = params.toString(); const qs = params.toString();
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`, { const res = await fetch(
// Em ambiente server, revalidação curta para manter leve `${process.env.NEXT_PUBLIC_BASE_URL ?? ""}/api/items?${qs}`,
next: { revalidate: 15 }, {
}); // Em ambiente server, revalidação curta para manter leve
next: { revalidate: 15 },
},
);
if (!res.ok) { if (!res.ok) {
return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 }; return { items: [], total: 0, page: 1, pageSize: 24, totalPages: 1 };
} }
return res.json(); 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 { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" }); const t = await getTranslations({ locale, namespace: "underConstruction" });
return { 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 }>; params: Promise<{ locale: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>; searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
@@ -69,5 +79,3 @@ export default async function LibraryPage({ params, searchParams }: {
</div> </div>
); );
} }
+238 -222
View File
@@ -1,8 +1,14 @@
"use client"; "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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,19 +17,13 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Search, SortAsc, SortDesc, Filter, X, Grid, List } from "lucide-react"; import { useInvalidateListings, useListings } from "@/lib/hooks/use-listings";
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 { useMyPurchaseIntentions } from "@/lib/hooks/use-purchase-intentions"; 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 { interface Listing {
listing_id: number; listing_id: number;
@@ -57,7 +57,7 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
const { trackSearch, trackFilter, trackPageView } = useMarketTelemetry(); const { trackSearch, trackFilter, trackPageView } = useMarketTelemetry();
const invalidateListings = useInvalidateListings(); const invalidateListings = useInvalidateListings();
const locale = useLocale(); const locale = useLocale();
const [page, setPage] = useState<number>(initial.page || 1); const [page, setPage] = useState<number>(initial.page || 1);
const [pageSize] = useState<number>(initial.pageSize || 20); const [pageSize] = useState<number>(initial.pageSize || 20);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -73,7 +73,12 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
const [activeTab, setActiveTab] = useState<string>("explore"); const [activeTab, setActiveTab] = useState<string>("explore");
// Usar React Query para buscar listings com atualização automática // 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, search: searchQuery || undefined,
server_name: serverName || undefined, server_name: serverName || undefined,
price_min: priceMin || undefined, price_min: priceMin || undefined,
@@ -144,7 +149,7 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
<div> <div>
<h1 className="text-3xl font-bold">Marketplace</h1> <h1 className="text-3xl font-bold">Marketplace</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
{activeTab === "explore" {activeTab === "explore"
? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis` ? `${total} ${total === 1 ? "anúncio" : "anúncios"} disponíveis`
: "Gerencie seus anúncios"} : "Gerencie seus anúncios"}
</p> </p>
@@ -161,226 +166,238 @@ export default function MarketClient({ initial }: { initial: InitialData }) {
<TabsList> <TabsList>
<TabsTrigger value="explore">Explorar</TabsTrigger> <TabsTrigger value="explore">Explorar</TabsTrigger>
{user && <TabsTrigger value="my-ads">Meus Anúncios</TabsTrigger>} {user && <TabsTrigger value="my-ads">Meus Anúncios</TabsTrigger>}
{user && ( {user && <MyPurchaseIntentionsTabTrigger />}
<MyPurchaseIntentionsTabTrigger />
)}
</TabsList> </TabsList>
{/* Aba: Explorar Anúncios */} {/* Aba: Explorar Anúncios */}
<TabsContent value="explore" className="space-y-6 mt-6"> <TabsContent value="explore" className="space-y-6 mt-6">
{/* Barra de busca */}
{/* Barra de busca */} <div className="relative">
<div className="relative"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input
<Input placeholder="Buscar anúncios por item ou descrição..."
placeholder="Buscar anúncios por item ou descrição..." value={searchQuery}
value={searchQuery} onChange={(e) => handleSearch(e.target.value)}
onChange={(e) => handleSearch(e.target.value)} className="pl-10 pr-4"
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} />
))}
</div> </div>
{/* Paginação */} {/* Filtros e controles */}
{totalPages > 1 && ( <div className="flex flex-wrap items-center gap-4">
<div className="flex items-center justify-center gap-2"> {/* 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 <Button
variant="outline" variant={viewMode === "grid" ? "default" : "ghost"}
onClick={() => setPage((p) => Math.max(1, p - 1))} size="sm"
disabled={page === 1} onClick={() => setViewMode("grid")}
> >
Anterior <Grid className="h-4 w-4" />
</Button> </Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button <Button
variant="outline" variant={viewMode === "list" ? "default" : "ghost"}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} size="sm"
disabled={page === totalPages} onClick={() => setViewMode("list")}
> >
Próxima <List className="h-4 w-4" />
</Button> </Button>
</div> </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 */} {/* Modal de criação de anúncio */}
{showCreateModal && ( {showCreateModal && (
@@ -437,4 +454,3 @@ function MyPurchaseIntentionsTabTrigger() {
</TabsTrigger> </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 type { Metadata } from "next";
import Header from "@/components/Header";
import { AnalyticsDashboard } from "@/components/marketplace/AnalyticsDashboard"; import { AnalyticsDashboard } from "@/components/marketplace/AnalyticsDashboard";
import { requireAuth } from "@/lib/auth/middleware";
import { NextRequest } from "next/server";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -38,4 +35,3 @@ export default async function AnalyticsPage({
</div> </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 type { Metadata } from "next";
import MarketClient from "./MarketClient"; import Header from "@/components/Header";
import { getActiveListings } from "@/lib/prisma/queries/listings"; import { getActiveListings } from "@/lib/prisma/queries/listings";
import MarketClient from "./MarketClient";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -46,12 +45,14 @@ export default async function MarketPage({
const sp = (await searchParams) ?? {}; const sp = (await searchParams) ?? {};
const page = typeof sp.page === "string" ? parseInt(sp.page, 10) : 1; 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 // Buscar anúncios ativos
const listingsData = await getActiveListings({ const listingsData = await getActiveListings({
search: typeof sp.search === "string" ? sp.search : undefined, 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, page,
pageSize, pageSize,
sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as sortBy: (typeof sp.sortBy === "string" ? sp.sortBy : "created_at") as
@@ -60,27 +61,34 @@ export default async function MarketPage({
| "price_tibia_coins" | "price_tibia_coins"
| "price_gold_coins" | "price_gold_coins"
| "views_count", | "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 // Converter Date para string para compatibilidade com o tipo do cliente
const serializedListings = listingsData.listings.map((listing) => ({ const serializedListings = listingsData.listings.map((listing) => ({
...listing, ...listing,
created_at: listing.created_at instanceof Date created_at:
? listing.created_at.toISOString() listing.created_at instanceof Date
: listing.created_at, ? listing.created_at.toISOString()
updated_at: listing.updated_at instanceof Date : listing.created_at,
? listing.updated_at.toISOString() updated_at:
: listing.updated_at, listing.updated_at instanceof Date
sold_at: listing.sold_at instanceof Date ? listing.updated_at.toISOString()
? listing.sold_at.toISOString() : listing.updated_at,
: listing.sold_at, sold_at:
auction_ends_at: listing.auction_ends_at instanceof Date listing.sold_at instanceof Date
? listing.auction_ends_at.toISOString() ? listing.sold_at.toISOString()
: listing.auction_ends_at, : listing.sold_at,
expires_at: listing.expires_at instanceof Date auction_ends_at:
? listing.expires_at.toISOString() listing.auction_ends_at instanceof Date
: listing.expires_at, ? listing.auction_ends_at.toISOString()
: listing.auction_ends_at,
expires_at:
listing.expires_at instanceof Date
? listing.expires_at.toISOString()
: listing.expires_at,
})); }));
return ( return (
+3 -3
View File
@@ -1,19 +1,19 @@
import { DonationFooterCard } from "@/components/donation-footer-card";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { EscrowBanner } from "@/components/sections/escrow-banner"; import { EscrowBanner } from "@/components/sections/escrow-banner";
import { EscrowExplanation } from "@/components/sections/escrow-explanation"; import { EscrowExplanation } from "@/components/sections/escrow-explanation";
import { FeaturedItems } from "@/components/sections/featured-items";
import { HeroSection } from "@/components/sections/hero-section"; import { HeroSection } from "@/components/sections/hero-section";
import { HowItWorks } from "@/components/sections/how-it-works"; import { HowItWorks } from "@/components/sections/how-it-works";
import { FeaturedItems } from "@/components/sections/featured-items";
import { MarketExample } from "@/components/sections/market-example"; import { MarketExample } from "@/components/sections/market-example";
import { MarketStats } from "@/components/sections/market-stats"; import { MarketStats } from "@/components/sections/market-stats";
import { DonationFooterCard } from "@/components/donation-footer-card";
export default function Home() { export default function Home() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<EscrowBanner /> <EscrowBanner />
<main role="main" aria-label="Página principal do Tibia Trade"> <main aria-label="Página principal do Tibia Trade">
<HeroSection /> <HeroSection />
<HowItWorks /> <HowItWorks />
<FeaturedItems /> <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 Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction"; import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -9,7 +9,7 @@ export async function generateMetadata({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: "underConstruction" }); const _t = await getTranslations({ locale, namespace: "underConstruction" });
const titles = { const titles = {
pt: "Preços - Estrutura de Custos", 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 Header from "@/components/Header";
import { UnderConstruction } from "@/components/under-construction"; import { UnderConstruction } from "@/components/under-construction";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({ export async function generateMetadata({
params, 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 { 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"; import { secureLogger } from "@/lib/secure-logger";
function sanitizeColumn(column: any): SQLiteColumnDefinition { 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 type = typeof column?.type === "string" ? column.type : "TEXT";
const notNull = column?.notNull === true; const notNull = column?.notNull === true;
const defaultValue = column?.defaultValue ?? undefined; const defaultValue = column?.defaultValue ?? undefined;
@@ -26,7 +30,10 @@ function sanitizeColumn(column: any): SQLiteColumnDefinition {
} satisfies 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); const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) { if (auth instanceof NextResponse) {
return auth; return auth;
@@ -38,14 +45,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const body = await request.json(); const body = await request.json();
const column = sanitizeColumn(body?.column ?? body); const column = sanitizeColumn(body?.column ?? body);
const created = addColumnToCategory({ category: name, column }); 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) { } catch (error) {
secureLogger.error("Falha ao adicionar coluna à categoria", error); secureLogger.error("Falha ao adicionar coluna à categoria", error);
return NextResponse.json( 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 }, { 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 { requireAdminUser } from "@/lib/auth/middleware";
import { import {
listCategoryTemplates,
createCategoryTemplate,
type SQLiteColumnDefinition,
type CreateCategoryInput, type CreateCategoryInput,
createCategoryTemplate,
listCategoryTemplates,
type SQLiteColumnDefinition,
} from "@/lib/items/sqlite-manager"; } from "@/lib/items/sqlite-manager";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
@@ -16,7 +16,10 @@ export async function GET(request: NextRequest) {
try { try {
const templates = listCategoryTemplates(); const templates = listCategoryTemplates();
return NextResponse.json({ success: true, categories: templates }, { status: 200 }); return NextResponse.json(
{ success: true, categories: templates },
{ status: 200 },
);
} catch (error) { } catch (error) {
secureLogger.error("Falha ao listar categorias dinâmicas", error); secureLogger.error("Falha ao listar categorias dinâmicas", error);
return NextResponse.json( return NextResponse.json(
@@ -27,7 +30,8 @@ export async function GET(request: NextRequest) {
} }
function sanitizeColumn(column: any): SQLiteColumnDefinition { 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 type = typeof column?.type === "string" ? column.type : "TEXT";
const notNull = column?.notNull === true; const notNull = column?.notNull === true;
const defaultValue = column?.defaultValue ?? undefined; const defaultValue = column?.defaultValue ?? undefined;
@@ -53,7 +57,8 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); 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) { if (!rawName) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Nome da categoria é obrigatório" }, { 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 columnsArray: unknown[] = Array.isArray(body?.columns)
const columns = columnsArray.map((column) => sanitizeColumn(column)).filter((column) => { ? body.columns
return !(column && ["id", "nome", "image_url", "image_local_path"].includes(column.name)); : [];
}); const columns = columnsArray
.map((column) => sanitizeColumn(column))
.filter((column) => {
return !(
column &&
["id", "nome", "image_url", "image_local_path"].includes(column.name)
);
});
const input: CreateCategoryInput = { const input: CreateCategoryInput = {
name: rawName, name: rawName,
@@ -76,7 +88,11 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
secureLogger.error("Falha ao criar categoria dinâmica", error); secureLogger.error("Falha ao criar categoria dinâmica", error);
return NextResponse.json( 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 }, { status: 400 },
); );
} }
+6 -6
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import { generateItemStructure } from "@/lib/ai/generate-item-structure"; import { generateItemStructure } from "@/lib/ai/generate-item-structure";
import { calculateTokenCost } from "@/lib/ai/token-pricing"; import { calculateTokenCost } from "@/lib/ai/token-pricing";
import { recordTokenUsage } from "@/lib/ai/token-usage"; import { recordTokenUsage } from "@/lib/ai/token-usage";
import { requireAdminUser } from "@/lib/auth/middleware";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request); const auth = await requireAdminUser(request);
@@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Forneça descrição ou ao menos uma imagem para análise.", 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 // 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); 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) // 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", model: result.model ?? "gemini-2.5-flash",
requestId, 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 { requireAdminUser } from "@/lib/auth/middleware";
import {
insertCategoryItem,
listCategoryTemplates,
updateCategoryItem,
} from "@/lib/items/sqlite-manager";
import { import {
createAdminItem, createAdminItem,
getItemById,
searchAdminItems, searchAdminItems,
updateAdminItem, updateAdminItem,
getItemById,
} from "@/lib/prisma/queries/items"; } 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); const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
} }
@@ -32,18 +35,25 @@ function parseList(value: unknown): string[] {
return []; return [];
} }
function normalizeDynamicFields(category: string, rawFields: Record<string, unknown> | undefined) { function normalizeDynamicFields(
category: string,
rawFields: Record<string, unknown> | undefined,
) {
if (!rawFields || typeof rawFields !== "object") { if (!rawFields || typeof rawFields !== "object") {
return {} as Record<string, unknown>; return {} as Record<string, unknown>;
} }
const templates = listCategoryTemplates(); const templates = listCategoryTemplates();
const template = templates.find((entry) => entry.name === category.toLowerCase()); const template = templates.find(
(entry) => entry.name === category.toLowerCase(),
);
if (!template) { if (!template) {
return rawFields; 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> = {}; const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawFields)) { for (const [key, value] of Object.entries(rawFields)) {
@@ -114,7 +124,7 @@ export async function GET(request: NextRequest) {
error: "Falha ao consultar itens", error: "Falha ao consultar itens",
message: error?.message || "Erro desconhecido", message: error?.message || "Erro desconhecido",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -128,21 +138,28 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const name = typeof body?.name === "string" ? body.name.trim() : ""; const name = typeof body?.name === "string" ? body.name.trim() : "";
const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : ""; const category =
const originServer = typeof body?.server === "string" ? body.server.trim() || null : null; typeof body?.category === "string"
? body.category.trim().toLowerCase()
: "";
const originServer =
typeof body?.server === "string" ? body.server.trim() || null : null;
if (!name || !category) { if (!name || !category) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Nome e categoria são obrigatórios." }, { success: false, error: "Nome e categoria são obrigatórios." },
{ status: 400 } { status: 400 },
); );
} }
const dynamicFields = normalizeDynamicFields(category, body?.fields); 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 = const imageLocalPath =
typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() || null : null; typeof body?.imageLocalPath === "string"
? body.imageLocalPath.trim() || null
: null;
const sqliteData = { const sqliteData = {
nome: name, nome: name,
@@ -158,16 +175,25 @@ export async function POST(request: NextRequest) {
const sqliteId = insertCategoryItem({ category, data: sqliteData }); const sqliteId = insertCategoryItem({ category, data: sqliteData });
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {}; const metadataBase =
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>; typeof body?.metadata === "object" && body.metadata !== null
? body.metadata
: {};
const metadata = { ...metadataBase, ...dynamicFields } as Record<
string,
unknown
>;
const itemServers = parseList(body?.servers); 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({ const item = await createAdminItem({
item_name: name, item_name: name,
category, 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_url: imageUrl,
image_local_path: imageLocalPath, image_local_path: imageLocalPath,
level: typeof body?.level === "number" ? body.level : null, level: typeof body?.level === "number" ? body.level : null,
@@ -177,7 +203,9 @@ export async function POST(request: NextRequest) {
servers: finalServers, servers: finalServers,
tags: parseList(body?.tags), tags: parseList(body?.tags),
description: description:
typeof body?.description === "string" ? body.description.trim() || null : null, typeof body?.description === "string"
? body.description.trim() || null
: null,
metadata, metadata,
source_table_name: category, source_table_name: category,
source_table_id: sqliteId, source_table_id: sqliteId,
@@ -188,7 +216,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
item, item,
}, },
{ status: 201 } { status: 201 },
); );
} catch (error: any) { } catch (error: any) {
console.error("[admin/items][POST] erro", error?.message); console.error("[admin/items][POST] erro", error?.message);
@@ -198,7 +226,7 @@ export async function POST(request: NextRequest) {
error: "Falha ao criar item", error: "Falha ao criar item",
message: error?.message || "Erro desconhecido", 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) { if (!Number.isFinite(id) || id <= 0) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID inválido." }, { success: false, error: "ID inválido." },
{ status: 400 } { status: 400 },
); );
} }
@@ -224,44 +252,67 @@ export async function PUT(request: NextRequest) {
if (!existing) { if (!existing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Item não encontrado." }, { 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) { if (category !== existing.category) {
return NextResponse.json( 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 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 = 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) { if (existing.source_table_id && existing.source_table_name) {
const sqliteData: Record<string, unknown> = { 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; if (imageUrl !== undefined) sqliteData.image_url = imageUrl;
else sqliteData.image_url = existing.image_url; 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; else sqliteData.image_local_path = existing.image_local_path;
for (const [key, value] of Object.entries(dynamicFields)) { for (const [key, value] of Object.entries(dynamicFields)) {
sqliteData[key] = value; 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 metadataBase =
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>; typeof body?.metadata === "object" && body.metadata !== null
? body.metadata
: existing.metadata;
const metadata = { ...metadataBase, ...dynamicFields } as Record<
string,
unknown
>;
const item = await updateAdminItem(id, { const item = await updateAdminItem(id, {
item_name: typeof body?.name === "string" ? body.name.trim() : undefined, item_name: typeof body?.name === "string" ? body.name.trim() : undefined,
@@ -279,8 +330,8 @@ export async function PUT(request: NextRequest) {
typeof body?.description === "string" typeof body?.description === "string"
? body.description.trim() ? body.description.trim()
: body?.description === null : body?.description === null
? null ? null
: undefined, : undefined,
metadata, metadata,
}); });
@@ -289,7 +340,7 @@ export async function PUT(request: NextRequest) {
success: true, success: true,
item, item,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error: any) { } catch (error: any) {
console.error("[admin/items][PUT] erro", error?.message); console.error("[admin/items][PUT] erro", error?.message);
@@ -299,8 +350,7 @@ export async function PUT(request: NextRequest) {
error: "Falha ao atualizar item", error: "Falha ao atualizar item",
message: error?.message || "Erro desconhecido", message: error?.message || "Erro desconhecido",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
+20 -24
View File
@@ -1,27 +1,24 @@
import { NextRequest, NextResponse } from "next/server"; import { randomUUID } from "node:crypto";
import { import { type NextRequest, NextResponse } from "next/server";
checkRateLimit, import { generateAccessToken, generateRefreshToken } from "@/lib/auth/jwt";
getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { loginSchema } from "@/lib/validation-schemas";
import { comparePassword } from "@/lib/auth/password"; import { comparePassword } from "@/lib/auth/password";
import { import {
generateAccessToken, getDefaultSubscriptionTier,
generateRefreshToken, getSubscriptionTierById,
} from "@/lib/auth/jwt"; } from "@/lib/prisma/queries/subscription-tiers";
import { import {
getUserByEmail, getUserByEmail,
getUserByUsername, getUserByUsername,
updateLastLogin, updateLastLogin,
} from "@/lib/prisma/queries/users"; } from "@/lib/prisma/queries/users";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
import { import {
getDefaultSubscriptionTier, checkRateLimit,
getSubscriptionTierById, createRateLimitResponse,
} from "@/lib/prisma/queries/subscription-tiers"; 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) { export async function POST(request: NextRequest) {
try { try {
@@ -55,7 +52,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -77,7 +74,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Email/username ou senha incorretos", error: "Email/username ou senha incorretos",
}, },
{ status: 401 } { status: 401 },
); );
} }
@@ -88,7 +85,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Conta desativada. Entre em contato com o suporte.", 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, success: false,
error: "Erro interno. Tente novamente mais tarde.", error: "Erro interno. Tente novamente mais tarde.",
}, },
{ status: 500 } { status: 500 },
); );
} }
@@ -121,7 +118,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Email/username ou senha incorretos", error: "Email/username ou senha incorretos",
}, },
{ status: 401 } { status: 401 },
); );
} }
@@ -181,7 +178,7 @@ export async function POST(request: NextRequest) {
}, },
accessToken, accessToken,
}, },
{ status: 200 } { status: 200 },
); );
// Adicionar refresh token e sessionId como cookies httpOnly // Adicionar refresh token e sessionId como cookies httpOnly
@@ -220,8 +217,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getUserById } from "@/lib/prisma/queries/users";
import { getUserCharacters } from "@/lib/prisma/queries/characters"; 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) { export async function GET(request: NextRequest) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
// Se retornou NextResponse, é um erro de autenticação // Se retornou NextResponse, é um erro de autenticação
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
// Buscar dados do usuário // Buscar dados do usuário
@@ -25,7 +25,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Usuário não encontrado", error: "Usuário não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -66,7 +66,7 @@ export async function GET(request: NextRequest) {
createdAt: char.created_at, createdAt: char.created_at,
})), })),
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -85,8 +85,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { verifyRefreshToken, generateAccessToken } from "@/lib/auth/jwt";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Refresh token não fornecido", error: "Refresh token não fornecido",
}, },
{ status: 401 } { status: 401 },
); );
} }
@@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Refresh token inválido ou expirado", error: "Refresh token inválido ou expirado",
}, },
{ status: 401 } { status: 401 },
); );
response.cookies.delete("refreshToken"); response.cookies.delete("refreshToken");
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
accessToken, accessToken,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao renovar token", { secureLogger.error("❌ Erro ao renovar token", {
@@ -74,8 +74,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { setSession } from "@/lib/redis/session";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { registerSchema } from "@/lib/validation-schemas"; 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) { export async function POST(request: NextRequest) {
try { try {
@@ -62,7 +51,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -76,7 +65,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Email já está em uso", error: "Email já está em uso",
}, },
{ status: 409 } { status: 409 },
); );
} }
@@ -88,7 +77,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Username já está em uso", error: "Username já está em uso",
}, },
{ status: 409 } { status: 409 },
); );
} }
@@ -104,7 +93,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: `Servidor "${serverName}" não existe ou não está ativo`, 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 // Criar usuário e personagens em transação atômica usando Prisma
const { prisma } = await import("@/lib/prisma/app"); 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 // Usar transação Prisma
const newUser = await prisma.$transaction(async (tx) => { const newUser = await prisma.$transaction(async (tx) => {
@@ -199,7 +191,7 @@ export async function POST(request: NextRequest) {
{ {
tierSlug: defaultTier?.slug ?? undefined, tierSlug: defaultTier?.slug ?? undefined,
tierLevel: defaultTier?.level ?? undefined, tierLevel: defaultTier?.level ?? undefined,
} },
); );
// Criar sessão no Redis // Criar sessão no Redis
@@ -235,7 +227,7 @@ export async function POST(request: NextRequest) {
}, },
accessToken, accessToken,
}, },
{ status: 201 } { status: 201 },
); );
// Adicionar refresh token e sessionId como cookies httpOnly // Adicionar refresh token e sessionId como cookies httpOnly
@@ -274,8 +266,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { cookies } from "next/headers";
import { secureLogger } from "@/lib/secure-logger"; import { type NextRequest, NextResponse } from "next/server";
import { getSession, refreshSession, deleteSession, setSession } from "@/lib/redis/session";
import { verifyAccessToken } from "@/lib/auth/jwt"; import { verifyAccessToken } from "@/lib/auth/jwt";
import { getMainCharacter } from "@/lib/prisma/queries/characters"; import { getMainCharacter } from "@/lib/prisma/queries/characters";
import {
getDefaultSubscriptionTier,
getSubscriptionTierById,
} from "@/lib/prisma/queries/subscription-tiers";
import { getUserById } from "@/lib/prisma/queries/users"; 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 * GET /api/auth/session
* Verifica e retorna dados da sessão atual * Verifica e retorna dados da sessão atual
*/ */
export async function GET(request: NextRequest) { export async function GET(_request: NextRequest) {
try { try {
// Buscar sessionId do cookie // Buscar sessionId do cookie
const cookieStore = await cookies(); const cookieStore = await cookies();
@@ -24,7 +32,7 @@ export async function GET(request: NextRequest) {
authenticated: false, authenticated: false,
error: "Nenhuma sessão encontrada", error: "Nenhuma sessão encontrada",
}, },
{ status: 401 } { status: 401 },
); );
} }
@@ -39,7 +47,7 @@ export async function GET(request: NextRequest) {
authenticated: false, authenticated: false,
error: "Sessão expirada ou inválida", error: "Sessão expirada ou inválida",
}, },
{ status: 401 } { status: 401 },
); );
response.cookies.delete("sessionId"); response.cookies.delete("sessionId");
@@ -58,21 +66,19 @@ export async function GET(request: NextRequest) {
authenticated: false, authenticated: false,
error: "Token expirado", error: "Token expirado",
}, },
{ status: 401 } { status: 401 },
); );
response.cookies.delete("sessionId"); response.cookies.delete("sessionId");
return response; return response;
} }
let tierInfo: let tierInfo: {
| { subscription_tier_id: number | null;
subscription_tier_id: number | null; name: string | null;
name: string | null; slug: string | null;
slug: string | null; level: number | null;
level: number | null; } | null = {
}
| null = {
subscription_tier_id: session.subscriptionTierId, subscription_tier_id: session.subscriptionTierId,
name: session.subscriptionTierName, name: session.subscriptionTierName,
slug: session.subscriptionTierSlug, slug: session.subscriptionTierSlug,
@@ -86,7 +92,9 @@ export async function GET(request: NextRequest) {
) { ) {
const dbUser = await getUserById(session.userId); const dbUser = await getUserById(session.userId);
const fetchedTier = dbUser?.subscription_tier const fetchedTier = dbUser?.subscription_tier
? await getSubscriptionTierById(dbUser.subscription_tier.subscription_tier_id) ? await getSubscriptionTierById(
dbUser.subscription_tier.subscription_tier_id,
)
: await getDefaultSubscriptionTier(); : await getDefaultSubscriptionTier();
tierInfo = fetchedTier tierInfo = fetchedTier
@@ -139,7 +147,7 @@ export async function GET(request: NextRequest) {
: null, : null,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao verificar sessão", { secureLogger.error("❌ Erro ao verificar sessão", {
@@ -154,7 +162,7 @@ export async function GET(request: NextRequest) {
authenticated: false, authenticated: false,
error: error instanceof Error ? error.message : "Erro interno", 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 * DELETE /api/auth/session
* Encerra a sessão atual * Encerra a sessão atual
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(_request: NextRequest) {
try { try {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("sessionId")?.value; const sessionId = cookieStore.get("sessionId")?.value;
@@ -177,7 +185,7 @@ export async function DELETE(request: NextRequest) {
success: true, success: true,
message: "Sessão encerrada", message: "Sessão encerrada",
}, },
{ status: 200 } { status: 200 },
); );
response.cookies.delete("sessionId"); response.cookies.delete("sessionId");
@@ -193,8 +201,7 @@ export async function DELETE(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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) * 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 { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection"; import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat"; import { Chat } from "@/lib/mongodb/models/Chat";
import { Message } from "@/lib/mongodb/models/Message"; import { Message } from "@/lib/mongodb/models/Message";
import { sendMessageSchema, getMessagesSchema } from "@/lib/validation/chat-schemas";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import {
getMessagesSchema,
sendMessageSchema,
} from "@/lib/validation/chat-schemas";
/** /**
* GET /api/chats/[chatId]/messages * GET /api/chats/[chatId]/messages
@@ -17,7 +20,7 @@ import { secureLogger } from "@/lib/secure-logger";
*/ */
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> } { params }: { params: Promise<{ chatId: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -26,7 +29,7 @@ export async function GET(
const { chatId } = await params; const { chatId } = await params;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const queryValidation = getMessagesSchema.safeParse({ const queryValidation = getMessagesSchema.safeParse({
page: searchParams.get("page"), page: searchParams.get("page"),
limit: searchParams.get("limit"), limit: searchParams.get("limit"),
@@ -39,7 +42,7 @@ export async function GET(
error: "Parâmetros inválidos", error: "Parâmetros inválidos",
details: queryValidation.error.issues, details: queryValidation.error.issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -53,14 +56,17 @@ export async function GET(
if (!chat) { if (!chat) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Chat não encontrado" }, { success: false, error: "Chat não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) { if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
return NextResponse.json( 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() }, $set: { read_at: new Date() },
} },
); );
// Atualizar contador de não lidas // Atualizar contador de não lidas
@@ -100,19 +106,17 @@ export async function GET(
await chat.save(); await chat.save();
// Serializar mensagens (ordem reversa para mostrar mais antigas primeiro) // Serializar mensagens (ordem reversa para mostrar mais antigas primeiro)
const serializedMessages = messages const serializedMessages = messages.reverse().map((msg) => ({
.reverse() _id: msg._id.toString(),
.map((msg) => ({ chat_id: msg.chat_id,
_id: msg._id.toString(), sender_id: msg.sender_id,
chat_id: msg.chat_id, content: msg.content,
sender_id: msg.sender_id, created_at: msg.created_at.toISOString(),
content: msg.content, read_at: msg.read_at?.toISOString() || null,
created_at: msg.created_at.toISOString(), edited_at: msg.edited_at?.toISOString() || null,
read_at: msg.read_at?.toISOString() || null, message_type: msg.message_type,
edited_at: msg.edited_at?.toISOString() || null, metadata: msg.metadata || null,
message_type: msg.message_type, }));
metadata: msg.metadata || null,
}));
return NextResponse.json( return NextResponse.json(
{ {
@@ -125,7 +129,7 @@ export async function GET(
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / limit),
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar mensagens", { secureLogger.error("❌ Erro ao buscar mensagens", {
@@ -135,7 +139,7 @@ export async function GET(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar mensagens" }, { success: false, error: "Erro ao buscar mensagens" },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -146,7 +150,7 @@ export async function GET(
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> } { params }: { params: Promise<{ chatId: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -164,7 +168,7 @@ export async function POST(
error: "Dados inválidos", error: "Dados inválidos",
details: validationResult.error.issues, details: validationResult.error.issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -178,14 +182,17 @@ export async function POST(
if (!chat) { if (!chat) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Chat não encontrado" }, { success: false, error: "Chat não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) { if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
return NextResponse.json( 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 // Atualizar chat
chat.last_message_at = new Date(); chat.last_message_at = new Date();
chat.last_message_preview = content.substring(0, 100); chat.last_message_preview = content.substring(0, 100);
if (chat.buyer_id === auth.userId) { if (chat.buyer_id === auth.userId) {
chat.seller_unread_count += 1; chat.seller_unread_count += 1;
} else { } else {
@@ -228,7 +235,7 @@ export async function POST(
}, },
}); });
} }
} catch (socketError) { } catch (_socketError) {
// Socket.io não disponível, mensagem já foi salva // Socket.io não disponível, mensagem já foi salva
secureLogger.warn("Socket.io não disponível, mensagem salva via REST", { secureLogger.warn("Socket.io não disponível, mensagem salva via REST", {
chatId, chatId,
@@ -248,7 +255,7 @@ export async function POST(
message_type: message.message_type, message_type: message.message_type,
}, },
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao enviar mensagem", { secureLogger.error("❌ Erro ao enviar mensagem", {
@@ -258,8 +265,7 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao enviar mensagem" }, { 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 * 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 { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection"; import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat"; import { Chat } from "@/lib/mongodb/models/Chat";
@@ -10,7 +10,7 @@ import { secureLogger } from "@/lib/secure-logger";
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> } { params }: { params: Promise<{ chatId: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -27,15 +27,18 @@ export async function GET(
if (!chat) { if (!chat) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Chat não encontrado" }, { success: false, error: "Chat não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
// Verificar se o usuário pertence ao chat // Verificar se o usuário pertence ao chat
if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) { if (chat.buyer_id !== auth.userId && chat.seller_id !== auth.userId) {
return NextResponse.json( 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, seller_cache: chat.seller_cache,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar chat", { secureLogger.error("❌ Erro ao buscar chat", {
@@ -69,8 +72,7 @@ export async function GET(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar chat" }, { 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 * POST /api/chats/mark-all-read
* Marca todas as mensagens de todos os chats do usuário como lidas * 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 { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection"; import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat"; 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 // Para cada chat, marcar mensagens não lidas como lidas
const updatePromises = chats.map(async (chat) => { const updatePromises = chats.map(async (chat) => {
const chatId = chat.chat_id; const chatId = chat.chat_id;
// Marcar mensagens como lidas // Marcar mensagens como lidas
await Message.updateMany( await Message.updateMany(
{ {
@@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
}, },
{ {
$set: { read_at: new Date() }, $set: { read_at: new Date() },
} },
); );
// Zerar contador de não lidas // Zerar contador de não lidas
@@ -45,7 +45,7 @@ export async function POST(request: NextRequest) {
} else { } else {
chat.seller_unread_count = 0; chat.seller_unread_count = 0;
} }
return chat.save(); return chat.save();
}); });
@@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
message: "Todos os chats marcados como lidos", message: "Todos os chats marcados como lidos",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao marcar chats como lidos", { secureLogger.error("❌ Erro ao marcar chats como lidos", {
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao marcar como lido" }, { 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 * 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 { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection"; import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat"; 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 { getMainCharacter } from "@/lib/prisma/queries/characters";
import { getListingById } from "@/lib/prisma/queries/listings";
import { getUserById } from "@/lib/prisma/queries/users"; import { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { createChatSchema } from "@/lib/validation/chat-schemas";
/** /**
* GET /api/chats * GET /api/chats
@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
chats: serializedChats, chats: serializedChats,
count: serializedChats.length, count: serializedChats.length,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar chats", { secureLogger.error("❌ Erro ao buscar chats", {
@@ -67,7 +67,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar chats" }, { 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", error: "Dados inválidos",
details: validationResult.error.issues, details: validationResult.error.issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
if (!listing) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { 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) { if (listing.seller_id === auth.userId) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Você não pode criar um chat com você mesmo" }, { 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") { if (listing.listing_status !== "active") {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para chat" }, { 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, already_exists: true,
}, },
{ status: 200 } { status: 200 },
); );
} }
@@ -163,7 +163,7 @@ export async function POST(request: NextRequest) {
if (!buyer) { if (!buyer) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Usuário não encontrado" }, { success: false, error: "Usuário não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
@@ -174,7 +174,7 @@ export async function POST(request: NextRequest) {
if (!seller) { if (!seller) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Vendedor não encontrado" }, { 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 item_image_url: null, // Imagem será obtida via item_id se necessário
price_pix: listing.price_pix ? Number(listing.price_pix) : null, price_pix: listing.price_pix ? Number(listing.price_pix) : null,
price_tibia_coins: listing.price_tibia_coins || 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, server_name: listing.server_name,
listing_status: listing.listing_status, listing_status: listing.listing_status,
}, },
@@ -248,7 +250,7 @@ export async function POST(request: NextRequest) {
}, },
already_exists: false, already_exists: false,
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao criar chat", { secureLogger.error("❌ Erro ao criar chat", {
@@ -258,8 +260,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao criar chat" }, { success: false, error: "Erro ao criar chat" },
{ status: 500 } { status: 500 },
); );
} }
} }
+3 -3
View File
@@ -2,7 +2,7 @@
* GET /api/chats/unread-count * GET /api/chats/unread-count
* Retorna contagem de usuários únicos com mensagens não vistas * 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 { requireAuth } from "@/lib/auth/middleware";
import { connectMongoDB } from "@/lib/mongodb/connection"; import { connectMongoDB } from "@/lib/mongodb/connection";
import { Chat } from "@/lib/mongodb/models/Chat"; import { Chat } from "@/lib/mongodb/models/Chat";
@@ -54,7 +54,7 @@ export async function GET(request: NextRequest) {
return total + chat.seller_unread_count; return total + chat.seller_unread_count;
}, 0), }, 0),
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar contagem de chats não lidos", { secureLogger.error("❌ Erro ao buscar contagem de chats não lidos", {
@@ -64,7 +64,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar contagem" }, { 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 { abacatePay } from "@/lib/abacatepay";
import { DONATION_PRODUCTS, DonorData } from "@/types/donation";
import { createDonationSchema } from "@/lib/validation-schemas";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { createDonationSchema } from "@/lib/validation-schemas";
import { DONATION_PRODUCTS } from "@/types/donation";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -72,8 +72,8 @@ export async function POST(request: NextRequest) {
if (validatedData.tier === "custom") { if (validatedData.tier === "custom") {
// Converter para centavos // Converter para centavos
finalAmount = Math.round(validatedData.customAmount! * 100); finalAmount = Math.round(validatedData.customAmount! * 100);
finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed( finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount?.toFixed(
2 2,
)}`; )}`;
secureLogger.info("💰 Valor customizado calculado", { secureLogger.info("💰 Valor customizado calculado", {
@@ -161,7 +161,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { getPaymentStatus } from "@/lib/payment-cache";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!pixId) { if (!pixId) {
return NextResponse.json( return NextResponse.json(
{ error: "pixId é obrigatório" }, { 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); console.error("Erro ao verificar status do pagamento:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao verificar status" }, { 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 { abacatePay } from "@/lib/abacatepay";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
if (!billingId) { if (!billingId) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID da cobrança é obrigatório" }, { 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:
error instanceof Error ? error.message : "Erro ao verificar cobrança", 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 fs from "node:fs";
import path from "node:path"; 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 { getItemByIdUnified } from "@/lib/db/queries/items";
import { buildItemImagePath } from "@/lib/item-images"; import { buildItemImagePath } from "@/lib/item-images";
import { getItemById } from "@/lib/items-db";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
@@ -17,12 +17,12 @@ export async function GET(req: NextRequest) {
if (itemId) { if (itemId) {
try { try {
const itemIdNum = parseInt(itemId, 10); const itemIdNum = parseInt(itemId, 10);
if (!isNaN(itemIdNum)) { if (!Number.isNaN(itemIdNum)) {
// Tentar primeiro buscar na tabela unificada PostgreSQL // Tentar primeiro buscar na tabela unificada PostgreSQL
// Se a tabela não existir, retornará null silenciosamente // Se a tabela não existir, retornará null silenciosamente
let item: any = null; let item: any = null;
item = await getItemByIdUnified(itemIdNum); item = await getItemByIdUnified(itemIdNum);
// Se encontrou na tabela unificada, validar o nome se fornecido // Se encontrou na tabela unificada, validar o nome se fornecido
if (item && itemName && item.item_name) { if (item && itemName && item.item_name) {
if (item.item_name.toLowerCase() !== itemName.toLowerCase()) { if (item.item_name.toLowerCase() !== itemName.toLowerCase()) {
@@ -30,24 +30,28 @@ export async function GET(req: NextRequest) {
item = null; item = null;
} }
} }
// Se não encontrou na tabela unificada, tentar SQLite legado // Se não encontrou na tabela unificada, tentar SQLite legado
// Passar itemName para buscar por ID E NOME (evita ambiguidade) // Passar itemName para buscar por ID E NOME (evita ambiguidade)
if (!item) { if (!item) {
item = getItemById(itemIdNum, itemName || undefined); item = getItemById(itemIdNum, itemName || undefined);
} }
if (item) { if (item) {
// PRIORIDADE: 1. image_local_path (local), 2. image_url (externa), 3. image (fallback) // PRIORIDADE: 1. image_local_path (local), 2. image_url (externa), 3. image (fallback)
const imageLocalPath = item.image_local_path || ""; const imageLocalPath = item.image_local_path || "";
const imageUrl = item.image_url || ""; const imageUrl = item.image_url || "";
const image = item.image || ""; const image = item.image || "";
// Tentar primeiro a imagem local // Tentar primeiro a imagem local
if (imageLocalPath) { if (imageLocalPath) {
const normalizedPath = imageLocalPath.replace(/\\/g, "/"); 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 // Se já for um caminho completo (começa com data/), usar diretamente
if (normalizedPath.startsWith("data/")) { if (normalizedPath.startsWith("data/")) {
imagePath = normalizedPath; imagePath = normalizedPath;
@@ -55,12 +59,12 @@ export async function GET(req: NextRequest) {
// Construir o caminho usando a função helper // Construir o caminho usando a função helper
imagePath = buildItemImagePath(category, normalizedPath); imagePath = buildItemImagePath(category, normalizedPath);
} }
// Verificar se o arquivo existe antes de usar // Verificar se o arquivo existe antes de usar
if (imagePath) { if (imagePath) {
const baseDir = path.join(process.cwd(), "data", "images"); const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), imagePath); const resolved = path.join(process.cwd(), imagePath);
if (resolved.startsWith(baseDir)) { if (resolved.startsWith(baseDir)) {
try { try {
await fs.promises.access(resolved); 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 // Se não encontrou imagem local ou não existe, usar URL externa como fallback
if (!imagePath && (imageUrl || image)) { if (!imagePath && (imageUrl || image)) {
const externalUrl = 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); return Response.redirect(externalUrl, 302);
} }
} }
@@ -99,7 +106,7 @@ export async function GET(req: NextRequest) {
// Permitir apenas caminhos sob data/images para segurança // Permitir apenas caminhos sob data/images para segurança
const baseDir = path.join(process.cwd(), "data", "images"); const baseDir = path.join(process.cwd(), "data", "images");
const resolved = path.join(process.cwd(), imagePath); const resolved = path.join(process.cwd(), imagePath);
if (!resolved.startsWith(baseDir)) { if (!resolved.startsWith(baseDir)) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@@ -111,18 +118,19 @@ export async function GET(req: NextRequest) {
ext === ".gif" ext === ".gif"
? "image/gif" ? "image/gif"
: ext === ".png" : ext === ".png"
? "image/png" ? "image/png"
: ext === ".webp" : ext === ".webp"
? "image/webp" ? "image/webp"
: ext === ".jpg" || ext === ".jpeg" : ext === ".jpg" || ext === ".jpeg"
? "image/jpeg" ? "image/jpeg"
: "application/octet-stream"; : "application/octet-stream";
return new Response(file, { status: 200, headers: { "Content-Type": contentType } }); return new Response(file, {
status: 200,
headers: { "Content-Type": contentType },
});
} catch (error) { } catch (error) {
console.error("[api/images] Erro ao ler arquivo:", error); console.error("[api/images] Erro ao ler arquivo:", error);
// Retornar 404 silenciosamente para não gerar erros no console // Retornar 404 silenciosamente para não gerar erros no console
return new Response("Not Found", { status: 404 }); 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"; import { prisma } from "@/lib/prisma/app";
export async function GET(_req: NextRequest) { export async function GET(_req: NextRequest) {
@@ -15,11 +15,6 @@ export async function GET(_req: NextRequest) {
.filter((category): category is string => Boolean(category)), .filter((category): category is string => Boolean(category)),
}); });
} catch (e: any) { } catch (e: any) {
return Response.json( return Response.json({ error: e?.message || "Erro" }, { status: 500 });
{ error: e?.message || "Erro" },
{ status: 500 }
);
} }
} }
+19 -9
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { searchItems, type ItemSort } from "@/lib/prisma/queries/items";
import { buildPublicItemImageUrl } from "@/lib/item-images"; import { buildPublicItemImageUrl } from "@/lib/item-images";
import { type ItemSort, searchItems } from "@/lib/prisma/queries/items";
function parseArray(param: string | null): string[] { function parseArray(param: string | null): string[] {
if (!param) return []; if (!param) return [];
@@ -28,7 +28,8 @@ export async function GET(req: NextRequest) {
const rarities = parseArray(searchParams.get("rarity")); const rarities = parseArray(searchParams.get("rarity"));
const server = searchParams.get("server") || undefined; const server = searchParams.get("server") || undefined;
const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase(); 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 page = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "24"); const pageSize = Number(searchParams.get("pageSize") || "24");
@@ -48,7 +49,11 @@ export async function GET(req: NextRequest) {
return { ...item, image_url: img, image: img }; return { ...item, image_url: img, image: img };
} }
if (img.startsWith("data/")) { 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"; const category = item.category || "other";
return { return {
@@ -60,8 +65,15 @@ export async function GET(req: NextRequest) {
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
return Response.json( 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) { } catch (error: any) {
console.error("[api/items] Erro:", error?.message, error?.stack); console.error("[api/items] Erro:", error?.message, error?.stack);
@@ -70,9 +82,7 @@ export async function GET(req: NextRequest) {
error: "Falha ao consultar itens", error: "Falha ao consultar itens",
message: error?.message || "Erro desconhecido", message: error?.message || "Erro desconhecido",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
+11 -12
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings"; import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/listings/:id/activate * POST /api/listings/:id/activate
@@ -9,7 +9,7 @@ import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -24,13 +24,13 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -43,7 +43,7 @@ export async function POST(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -53,7 +53,7 @@ export async function POST(
success: false, success: false,
error: "Você não tem permissão para reativar este anúncio", 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, success: false,
error: "Apenas anúncios pausados podem ser reativados", error: "Apenas anúncios pausados podem ser reativados",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -78,7 +78,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao reativar anúncio", error: "Erro ao reativar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
@@ -93,7 +93,7 @@ export async function POST(
listing: updated, listing: updated,
message: "Anúncio reativado com sucesso", message: "Anúncio reativado com sucesso",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao reativar anúncio", { secureLogger.error("❌ Erro ao reativar anúncio", {
@@ -105,8 +105,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao reativar anúncio", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { placeBid } from "@/lib/prisma/queries/listings"; import { placeBid } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications"; import { createNotification } from "@/lib/prisma/queries/notifications";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/listings/[id]/bid * POST /api/listings/[id]/bid
@@ -10,7 +10,7 @@ import { createNotification } from "@/lib/prisma/queries/notifications";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -19,10 +19,10 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" }, { 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) { if (!amount || typeof amount !== "number" || amount <= 0) {
return NextResponse.json( 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)) { if (!payment_method || !["pix", "tc", "gp"].includes(payment_method)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" }, { success: false, error: "Método de pagamento inválido" },
{ status: 400 } { status: 400 },
); );
} }
// Colocar lance // 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) { if (!result.success) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: result.error }, { success: false, error: result.error },
{ status: 400 } { status: 400 },
); );
} }
// Criar notificação para o vendedor // Criar notificação para o vendedor
await createNotification({ await createNotification({
user_id: result.listing!.seller_id, user_id: result.listing?.seller_id,
notification_type: "bid", notification_type: "bid",
title: "Novo lance!", 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, related_id: listingId,
send_whatsapp: false, send_whatsapp: false,
}); });
@@ -69,7 +77,7 @@ export async function POST(
user_id: result.previousBidderId, user_id: result.previousBidderId,
notification_type: "bid", notification_type: "bid",
title: "Lance superado", 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, related_id: listingId,
send_whatsapp: false, send_whatsapp: false,
}); });
@@ -84,8 +92,12 @@ export async function POST(
metadata: { metadata: {
bid_amount: amount, bid_amount: amount,
payment_method: payment_method, payment_method: payment_method,
previous_bid: result.listing!.current_bid_amount ? Number(result.listing!.current_bid_amount) : null, previous_bid: result.listing?.current_bid_amount
min_increment: result.listing!.min_bid_increment ? Number(result.listing!.min_bid_increment) : null, ? 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, listing: result.listing,
message: "Lance realizado com sucesso", message: "Lance realizado com sucesso",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao processar lance", { secureLogger.error("❌ Erro ao processar lance", {
@@ -112,8 +124,7 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao processar lance" }, { 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { prisma } from "@/lib/prisma/app"; import { prisma } from "@/lib/prisma/app";
import { getListingById } from "@/lib/prisma/queries/listings";
import { trackEvent } from "@/lib/prisma/queries/telemetry"; import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* GET /api/listings/[id]/bids * GET /api/listings/[id]/bids
@@ -11,7 +11,7 @@ import { trackEvent } from "@/lib/prisma/queries/telemetry";
*/ */
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -19,10 +19,10 @@ export async function GET(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" }, { success: false, error: "ID de anúncio inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -31,14 +31,14 @@ export async function GET(
if (!listing) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { success: false, error: "Anúncio não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
if (!listing.is_auction) { if (!listing.is_auction) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Este anúncio não é um leilão" }, { 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, bids: formattedBids,
total: formattedBids.length, total: formattedBids.length,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar histórico de lances", { secureLogger.error("❌ Erro ao buscar histórico de lances", {
@@ -102,8 +102,7 @@ export async function GET(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar histórico de lances" }, { 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings";
import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites"; 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 * POST /api/listings/:id/favorite
@@ -10,7 +10,7 @@ import { isFavorite, toggleFavorite } from "@/lib/prisma/queries/favorites";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -25,13 +25,13 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -44,17 +44,20 @@ export async function POST(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
// Toggle favorite // Toggle favorite
const favorited = await toggleFavorite(auth.userId, listingId); const favorited = await toggleFavorite(auth.userId, listingId);
secureLogger.info(`✅ Anúncio ${favorited ? "favoritado" : "desfavoritado"}`, { secureLogger.info(
listingId, `✅ Anúncio ${favorited ? "favoritado" : "desfavoritado"}`,
userId: auth.userId, {
}); listingId,
userId: auth.userId,
},
);
return NextResponse.json( return NextResponse.json(
{ {
@@ -62,7 +65,7 @@ export async function POST(
favorited, favorited,
message: favorited ? "Anúncio favoritado" : "Anúncio desfavoritado", message: favorited ? "Anúncio favoritado" : "Anúncio desfavoritado",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao favoritar/desfavoritar anúncio", { secureLogger.error("❌ Erro ao favoritar/desfavoritar anúncio", {
@@ -75,7 +78,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao favoritar/desfavoritar anúncio", error: "Erro ao favoritar/desfavoritar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -86,7 +89,7 @@ export async function POST(
*/ */
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -101,13 +104,13 @@ export async function GET(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -118,7 +121,7 @@ export async function GET(
success: true, success: true,
favorited, favorited,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao verificar favorito", { secureLogger.error("❌ Erro ao verificar favorito", {
@@ -130,8 +133,7 @@ export async function GET(
success: false, success: false,
error: "Erro ao verificar favorito", error: "Erro ao verificar favorito",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; 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 { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { import {
getPurchaseIntentionById, getPurchaseIntentionById,
updatePurchaseIntentionStatus, updatePurchaseIntentionStatus,
} from "@/lib/prisma/queries/purchase-intentions"; } 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 { 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] * GET /api/listings/[id]/intentions/[intentionId]
@@ -19,7 +19,7 @@ import { getMainCharacter } from "@/lib/prisma/queries/characters";
*/ */
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string; intentionId: string }> } { params }: { params: Promise<{ id: string; intentionId: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -27,10 +27,10 @@ export async function GET(
const { intentionId: intentionIdParam } = await params; const { intentionId: intentionIdParam } = await params;
const intentionId = parseInt(intentionIdParam, 10); const intentionId = parseInt(intentionIdParam, 10);
if (isNaN(intentionId)) { if (Number.isNaN(intentionId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de intenção inválido" }, { success: false, error: "ID de intenção inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -38,15 +38,21 @@ export async function GET(
if (!intention) { if (!intention) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Intenção não encontrada" }, { success: false, error: "Intenção não encontrada" },
{ status: 404 } { status: 404 },
); );
} }
// Verificar permissões: vendedor ou comprador // 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( 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, success: true,
intention, intention,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar intenção", { secureLogger.error("❌ Erro ao buscar intenção", {
@@ -65,7 +71,7 @@ export async function GET(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar intenção" }, { success: false, error: "Erro ao buscar intenção" },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -76,7 +82,7 @@ export async function GET(
*/ */
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string; intentionId: string }> } { params }: { params: Promise<{ id: string; intentionId: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -84,10 +90,10 @@ export async function PATCH(
const { intentionId: intentionIdParam } = await params; const { intentionId: intentionIdParam } = await params;
const intentionId = parseInt(intentionIdParam, 10); const intentionId = parseInt(intentionIdParam, 10);
if (isNaN(intentionId)) { if (Number.isNaN(intentionId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de intenção inválido" }, { 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)) { if (!status || !["accepted", "rejected"].includes(status)) {
return NextResponse.json( 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) { if (!intention) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Intenção não encontrada" }, { success: false, error: "Intenção não encontrada" },
{ status: 404 } { status: 404 },
); );
} }
// Verificar se o usuário é o vendedor // Verificar se o usuário é o vendedor
if (intention.listing?.seller_id !== authResult.userId) { if (intention.listing?.seller_id !== authResult.userId) {
return NextResponse.json( 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") { if (intention.status !== "pending") {
return NextResponse.json( return NextResponse.json(
{ success: false, error: `Esta intenção já foi ${intention.status}` }, { 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) // Verificar se o anúncio ainda está disponível (ativo ou em escrow)
const listing = await getListingById(intention.listing_id); 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( return NextResponse.json(
{ success: false, error: "Anúncio não está mais disponível" }, { 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( const updatedIntention = await updatePurchaseIntentionStatus(
intentionId, intentionId,
status, status,
sellerResponse sellerResponse,
); );
await sendSellerResponseToChat({ await sendSellerResponseToChat({
@@ -159,19 +175,23 @@ export async function PATCH(
user_id: intention.buyer_id, user_id: intention.buyer_id,
notification_type: status === "accepted" ? "purchase" : "system", notification_type: status === "accepted" ? "purchase" : "system",
title: status === "accepted" ? "Intenção aceita!" : "Intenção rejeitada", title: status === "accepted" ? "Intenção aceita!" : "Intenção rejeitada",
message: status === "accepted" message:
? `O vendedor aceitou sua intenção de compra do item "${intention.listing?.item_name}"` status === "accepted"
: `O vendedor rejeitou sua intenção de compra do item "${intention.listing?.item_name}"`, ? `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, related_id: intention.listing_id,
send_whatsapp: false, send_whatsapp: false,
}); });
secureLogger.info(`✅ Intenção ${status === "accepted" ? "aceita" : "rejeitada"}`, { secureLogger.info(
userId: authResult.userId, `✅ Intenção ${status === "accepted" ? "aceita" : "rejeitada"}`,
intentionId, {
listingId: intention.listing_id, userId: authResult.userId,
buyerId: intention.buyer_id, intentionId,
}); listingId: intention.listing_id,
buyerId: intention.buyer_id,
},
);
return NextResponse.json( return NextResponse.json(
{ {
@@ -179,7 +199,7 @@ export async function PATCH(
intention: updatedIntention, intention: updatedIntention,
message: `Intenção ${status === "accepted" ? "aceita" : "rejeitada"} com sucesso`, message: `Intenção ${status === "accepted" ? "aceita" : "rejeitada"} com sucesso`,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao atualizar intenção", { secureLogger.error("❌ Erro ao atualizar intenção", {
@@ -189,7 +209,7 @@ export async function PATCH(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao atualizar intenção" }, { success: false, error: "Erro ao atualizar intenção" },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -202,7 +222,10 @@ async function sendSellerResponseToChat({
sellerId, sellerId,
}: { }: {
listing: Awaited<ReturnType<typeof getListingById>>; listing: Awaited<ReturnType<typeof getListingById>>;
intention: Exclude<Awaited<ReturnType<typeof getPurchaseIntentionById>>, null>; intention: Exclude<
Awaited<ReturnType<typeof getPurchaseIntentionById>>,
null
>;
status: "accepted" | "rejected"; status: "accepted" | "rejected";
sellerResponse?: string; sellerResponse?: string;
sellerId: number; sellerId: number;
@@ -225,12 +248,13 @@ async function sendSellerResponseToChat({
let chat = await Chat.findOne({ chat_id: chatId }); let chat = await Chat.findOne({ chat_id: chatId });
if (!chat) { if (!chat) {
const [buyer, seller, buyerCharacter, sellerCharacter] = await Promise.all([ const [buyer, seller, buyerCharacter, sellerCharacter] =
getUserById(intention.buyer_id), await Promise.all([
getUserById(listing.seller_id), getUserById(intention.buyer_id),
getMainCharacter(intention.buyer_id), getUserById(listing.seller_id),
getMainCharacter(listing.seller_id), getMainCharacter(intention.buyer_id),
]); getMainCharacter(listing.seller_id),
]);
chat = new Chat({ chat = new Chat({
chat_id: chatId, 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings"; import { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { import {
createPurchaseIntention, createPurchaseIntention,
getPurchaseIntentionsByListing,
hasPendingIntention,
getPendingIntentionByBuyerAndListing, getPendingIntentionByBuyerAndListing,
getPurchaseIntentionsByListing,
} from "@/lib/prisma/queries/purchase-intentions"; } from "@/lib/prisma/queries/purchase-intentions";
import { createNotification } from "@/lib/prisma/queries/notifications";
import { trackEvent } from "@/lib/prisma/queries/telemetry"; import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/listings/[id]/intentions * POST /api/listings/[id]/intentions
@@ -17,7 +16,7 @@ import { trackEvent } from "@/lib/prisma/queries/telemetry";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -26,10 +25,10 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" }, { success: false, error: "ID de anúncio inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -41,10 +40,13 @@ export async function POST(
custom_offer_currency, custom_offer_currency,
} = body; } = 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( return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" }, { success: false, error: "Método de pagamento inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -53,15 +55,19 @@ export async function POST(
if (!listing) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { success: false, error: "Anúncio não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
// Validar se não é o vendedor // Validar se não é o vendedor
if (listing.seller_id === auth.userId) { if (listing.seller_id === auth.userId) {
return NextResponse.json( 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") { if (listing.listing_status !== "active") {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para compra" }, { 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) // Validar se é leilão (leilões não podem ter intenções, apenas lances)
if (listing.is_auction) { if (listing.is_auction) {
return NextResponse.json( 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`, error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods, available_methods: availableMethods,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -107,9 +116,10 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ {
success: false, 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( return NextResponse.json(
{ {
success: false, 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 = 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 ? custom_offer_currency
: payment_method; : payment_method;
@@ -136,7 +148,10 @@ export async function POST(
} }
// Verificar se já existe uma intenção pendente // Verificar se já existe uma intenção pendente
const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId); const existingIntention = await getPendingIntentionByBuyerAndListing(
listingId,
auth.userId,
);
if (existingIntention) { if (existingIntention) {
// Se já existe e ainda pode trocar o método // Se já existe e ainda pode trocar o método
if (existingIntention.payment_method_change_count < 1) { if (existingIntention.payment_method_change_count < 1) {
@@ -144,23 +159,27 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ {
success: false, 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, existing_intention_id: existingIntention.intention_id,
can_change_payment_method: true, can_change_payment_method: true,
payment_method_change_count: existingIntention.payment_method_change_count, payment_method_change_count:
message: "Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento", 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 { } else {
// Já atingiu o limite de trocas // Já atingiu o limite de trocas
return NextResponse.json( return NextResponse.json(
{ {
success: false, 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, existing_intention_id: existingIntention.intention_id,
}, },
{ status: 400 } { status: 400 },
); );
} }
} }
@@ -197,7 +216,9 @@ export async function POST(
payment_method, payment_method,
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null, listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
listing_price_tc: listing.price_tibia_coins || 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_amount: normalizedOfferValue,
custom_offer_currency: normalizedOfferCurrency, custom_offer_currency: normalizedOfferCurrency,
}, },
@@ -214,9 +235,10 @@ export async function POST(
{ {
success: true, success: true,
intention, 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) { } catch (error) {
secureLogger.error("❌ Erro ao criar intenção de compra", { secureLogger.error("❌ Erro ao criar intenção de compra", {
@@ -226,7 +248,7 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao criar intenção de compra" }, { 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( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) return authResult; if (authResult instanceof NextResponse) return authResult;
const auth = authResult; const auth = authResult;
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" }, { success: false, error: "ID de anúncio inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -258,15 +280,18 @@ export async function GET(
if (!listing) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { success: false, error: "Anúncio não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
// Apenas o vendedor pode ver as intenções // Apenas o vendedor pode ver as intenções
if (listing.seller_id !== auth.userId) { if (listing.seller_id !== auth.userId) {
return NextResponse.json( 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, intentions,
count: intentions.length, count: intentions.length,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar intenções de compra", { secureLogger.error("❌ Erro ao buscar intenções de compra", {
@@ -307,8 +332,7 @@ export async function GET(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar intenções de compra" }, { 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; 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 * POST /api/listings/:id/mark-sold
@@ -9,7 +9,7 @@ import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -24,13 +24,13 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -43,7 +43,7 @@ export async function POST(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -53,18 +53,22 @@ export async function POST(
success: false, success: false,
error: "Você não tem permissão para marcar este anúncio como vendido", 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 // 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( return NextResponse.json(
{ {
success: false, 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 // Converter campos para formato esperado
const updated = { const updated = {
...updatedListing, ...updatedListing,
price_pix: updatedListing.price_pix ? Number(updatedListing.price_pix) : null, price_pix: updatedListing.price_pix
current_bid_amount: updatedListing.current_bid_amount ? Number(updatedListing.current_bid_amount) : null, ? Number(updatedListing.price_pix)
min_bid_increment: updatedListing.min_bid_increment ? Number(updatedListing.min_bid_increment) : null, : null,
initial_price_pix: updatedListing.initial_price_pix ? Number(updatedListing.initial_price_pix) : null, current_bid_amount: updatedListing.current_bid_amount
price_gold_coins: updatedListing.price_gold_coins ? Number(updatedListing.price_gold_coins) : null, ? Number(updatedListing.current_bid_amount)
initial_price_gold_coins: updatedListing.initial_price_gold_coins ? Number(updatedListing.initial_price_gold_coins) : null, : 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, seller_character_name: character?.character_name || null,
}; };
@@ -105,7 +121,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao marcar anúncio como vendido", error: "Erro ao marcar anúncio como vendido",
}, },
{ status: 500 } { status: 500 },
); );
} }
@@ -120,7 +136,7 @@ export async function POST(
listing: updated, listing: updated,
message: "Anúncio marcado como vendido com sucesso", message: "Anúncio marcado como vendido com sucesso",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao marcar anúncio como vendido", { secureLogger.error("❌ Erro ao marcar anúncio como vendido", {
@@ -132,8 +148,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao marcar anúncio como vendido", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListingById, updateListing } from "@/lib/prisma/queries/listings"; import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/listings/:id/pause * POST /api/listings/:id/pause
@@ -9,7 +9,7 @@ import { getListingById, updateListing } from "@/lib/prisma/queries/listings";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -24,13 +24,13 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -43,7 +43,7 @@ export async function POST(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -53,7 +53,7 @@ export async function POST(
success: false, success: false,
error: "Você não tem permissão para pausar este anúncio", 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, success: false,
error: "Apenas anúncios ativos podem ser pausados", error: "Apenas anúncios ativos podem ser pausados",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -79,7 +79,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao pausar anúncio", error: "Erro ao pausar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
@@ -94,7 +94,7 @@ export async function POST(
listing: updated, listing: updated,
message: "Anúncio pausado com sucesso", message: "Anúncio pausado com sucesso",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao pausar anúncio", { secureLogger.error("❌ Erro ao pausar anúncio", {
@@ -106,8 +106,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao pausar anúncio", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListingById } from "@/lib/prisma/queries/listings"; import { getListingById } from "@/lib/prisma/queries/listings";
import { createNotification } from "@/lib/prisma/queries/notifications"; import { createNotification } from "@/lib/prisma/queries/notifications";
import { import {
createPurchaseIntention, createPurchaseIntention,
hasPendingIntention,
getPendingIntentionByBuyerAndListing, getPendingIntentionByBuyerAndListing,
} from "@/lib/prisma/queries/purchase-intentions"; } from "@/lib/prisma/queries/purchase-intentions";
import { trackEvent } from "@/lib/prisma/queries/telemetry"; import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/listings/[id]/purchase * POST /api/listings/[id]/purchase
@@ -16,7 +15,7 @@ import { trackEvent } from "@/lib/prisma/queries/telemetry";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -25,10 +24,10 @@ export async function POST(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" }, { success: false, error: "ID de anúncio inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -43,7 +42,7 @@ export async function POST(
if (!payment_method) { if (!payment_method) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Método de pagamento é obrigatório" }, { success: false, error: "Método de pagamento é obrigatório" },
{ status: 400 } { status: 400 },
); );
} }
@@ -52,7 +51,7 @@ export async function POST(
if (!listing) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { 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) { if (listing.seller_id === auth.userId) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Você não pode comprar seu próprio anúncio" }, { 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") { if (listing.listing_status !== "active") {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não está disponível para compra" }, { 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) // Validar se é leilão (leilões não podem ser comprados diretamente, apenas com lances)
if (listing.is_auction) { if (listing.is_auction) {
return NextResponse.json( 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`, error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods, available_methods: availableMethods,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -111,9 +113,10 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ {
success: false, 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( return NextResponse.json(
{ {
success: false, 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 = 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 ? custom_offer_currency
: normalizedMethod; : normalizedMethod;
@@ -140,7 +145,10 @@ export async function POST(
} }
// Verificar se já existe uma intenção pendente // Verificar se já existe uma intenção pendente
const existingIntention = await getPendingIntentionByBuyerAndListing(listingId, auth.userId); const existingIntention = await getPendingIntentionByBuyerAndListing(
listingId,
auth.userId,
);
if (existingIntention) { if (existingIntention) {
// Se já existe e ainda pode trocar o método // Se já existe e ainda pode trocar o método
if (existingIntention.payment_method_change_count < 1) { if (existingIntention.payment_method_change_count < 1) {
@@ -148,23 +156,27 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ {
success: false, 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, existing_intention_id: existingIntention.intention_id,
can_change_payment_method: true, can_change_payment_method: true,
payment_method_change_count: existingIntention.payment_method_change_count, payment_method_change_count:
message: "Use o endpoint PATCH /api/purchase-intentions/[intentionId]/payment-method para trocar o método de pagamento", 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 { } else {
// Já atingiu o limite de trocas // Já atingiu o limite de trocas
return NextResponse.json( return NextResponse.json(
{ {
success: false, 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, existing_intention_id: existingIntention.intention_id,
}, },
{ status: 400 } { status: 400 },
); );
} }
} }
@@ -201,7 +213,9 @@ export async function POST(
payment_method: normalizedMethod, payment_method: normalizedMethod,
listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null, listing_price_pix: listing.price_pix ? Number(listing.price_pix) : null,
listing_price_tc: listing.price_tibia_coins || 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_amount: normalizedOfferValue,
custom_offer_currency: normalizedOfferCurrency, custom_offer_currency: normalizedOfferCurrency,
}, },
@@ -218,9 +232,10 @@ export async function POST(
{ {
success: true, success: true,
intention, 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) { } catch (error) {
secureLogger.error("❌ Erro ao criar intenção de compra", { secureLogger.error("❌ Erro ao criar intenção de compra", {
@@ -230,8 +245,7 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao criar intenção de compra" }, { 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger"; import { optionalAuth, requireAuth } from "@/lib/auth/middleware";
import { requireAuth, optionalAuth } from "@/lib/auth/middleware";
import { import {
deleteListing,
getListingById, getListingById,
updateListing, updateListing,
deleteListing,
} from "@/lib/prisma/queries/listings"; } from "@/lib/prisma/queries/listings";
import { hasPendingIntention } from "@/lib/prisma/queries/purchase-intentions"; import { hasPendingIntention } from "@/lib/prisma/queries/purchase-intentions";
import { secureLogger } from "@/lib/secure-logger";
import { updateListingSchema } from "@/lib/validation-schemas"; import { updateListingSchema } from "@/lib/validation-schemas";
/** /**
@@ -15,20 +15,20 @@ import { updateListingSchema } from "@/lib/validation-schemas";
*/ */
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const auth = await optionalAuth(request); const auth = await optionalAuth(request);
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -40,14 +40,13 @@ export async function GET(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
const hasPendingForUser = const hasPendingForUser = auth?.userId
auth && auth.userId ? await hasPendingIntention(listingId, auth.userId)
? await hasPendingIntention(listingId, auth.userId) : false;
: false;
return NextResponse.json( return NextResponse.json(
{ {
@@ -57,7 +56,7 @@ export async function GET(
has_pending_intention_for_current_user: hasPendingForUser, has_pending_intention_for_current_user: hasPendingForUser,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar anúncio", { secureLogger.error("❌ Erro ao buscar anúncio", {
@@ -70,7 +69,7 @@ export async function GET(
success: false, success: false,
error: "Erro ao buscar anúncio", error: "Erro ao buscar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -81,7 +80,7 @@ export async function GET(
*/ */
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -96,13 +95,13 @@ export async function PATCH(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -115,7 +114,7 @@ export async function PATCH(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -125,7 +124,7 @@ export async function PATCH(
success: false, success: false,
error: "Você não tem permissão para editar este anúncio", 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", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -159,16 +158,37 @@ export async function PATCH(
price_tibia_coins?: number; price_tibia_coins?: number;
price_gold_coins?: number; price_gold_coins?: number;
description?: string; description?: string;
listing_status?: "active" | "sold" | "cancelled" | "in_escrow" | "expired"; listing_status?:
| "active"
| "sold"
| "cancelled"
| "in_escrow"
| "expired";
current_bid_amount?: number; current_bid_amount?: number;
current_bidder_id?: number; current_bidder_id?: number;
} = { } = {
...(validatedData.quantity !== undefined && { quantity: validatedData.quantity }), ...(validatedData.quantity !== undefined && {
...(validatedData.price_pix !== null && validatedData.price_pix !== undefined && { price_pix: validatedData.price_pix }), quantity: validatedData.quantity,
...(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.price_pix !== null &&
...(validatedData.description !== null && validatedData.description !== undefined && { description: validatedData.description }), validatedData.price_pix !== undefined && {
...(validatedData.listing_status !== undefined && { listing_status: validatedData.listing_status }), 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 // Atualizar anúncio
@@ -180,7 +200,7 @@ export async function PATCH(
success: false, success: false,
error: "Erro ao atualizar anúncio", error: "Erro ao atualizar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
@@ -195,7 +215,7 @@ export async function PATCH(
success: true, success: true,
listing: updatedListing, listing: updatedListing,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao atualizar anúncio", { secureLogger.error("❌ Erro ao atualizar anúncio", {
@@ -208,7 +228,7 @@ export async function PATCH(
success: false, success: false,
error: "Erro ao atualizar anúncio", error: "Erro ao atualizar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -219,7 +239,7 @@ export async function PATCH(
*/ */
export async function DELETE( export async function DELETE(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Autenticação obrigatória // Autenticação obrigatória
@@ -234,13 +254,13 @@ export async function DELETE(
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -253,7 +273,7 @@ export async function DELETE(
success: false, success: false,
error: "Anúncio não encontrado", error: "Anúncio não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -263,7 +283,7 @@ export async function DELETE(
success: false, success: false,
error: "Você não tem permissão para cancelar este anúncio", 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, success: false,
error: "Erro ao cancelar anúncio", error: "Erro ao cancelar anúncio",
}, },
{ status: 500 } { status: 500 },
); );
} }
@@ -291,7 +311,7 @@ export async function DELETE(
success: true, success: true,
message: "Anúncio cancelado com sucesso", message: "Anúncio cancelado com sucesso",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao cancelar anúncio", { secureLogger.error("❌ Erro ao cancelar anúncio", {
@@ -304,8 +324,7 @@ export async function DELETE(
success: false, success: false,
error: "Erro ao cancelar anúncio", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { getListingById } from "@/lib/prisma/queries/listings";
import { getMainCharacterByUserId } from "@/lib/prisma/queries/characters"; 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 * GET /api/listings/[id]/seller
* Retorna informações do vendedor (nome do personagem) * Retorna informações do vendedor (nome do personagem)
*/ */
export async function GET( export async function GET(
request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de anúncio inválido" }, { success: false, error: "ID de anúncio inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -25,12 +25,15 @@ export async function GET(
if (!listing) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { success: false, error: "Anúncio não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
// Buscar personagem principal do vendedor no servidor do listing // 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( return NextResponse.json(
{ {
@@ -41,7 +44,7 @@ export async function GET(
server_name: listing.server_name, server_name: listing.server_name,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar informações do vendedor", { secureLogger.error("❌ Erro ao buscar informações do vendedor", {
@@ -51,8 +54,7 @@ export async function GET(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar informações do vendedor" }, { 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { incrementViews } from "@/lib/prisma/queries/listings"; import { incrementViews } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/listings/[id]/views * POST /api/listings/[id]/views
@@ -8,20 +8,20 @@ import { incrementViews } from "@/lib/prisma/queries/listings";
* Não requer autenticação (público) * Não requer autenticação (público)
*/ */
export async function POST( export async function POST(
request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const { id } = await params; const { id } = await params;
const listingId = parseInt(id, 10); const listingId = parseInt(id, 10);
if (isNaN(listingId)) { if (Number.isNaN(listingId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de anúncio inválido", error: "ID de anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -33,7 +33,7 @@ export async function POST(
success: true, success: true,
message: "View incrementada", message: "View incrementada",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao incrementar views", { secureLogger.error("❌ Erro ao incrementar views", {
@@ -46,8 +46,7 @@ export async function POST(
success: false, success: false,
error: "Erro ao incrementar views", error: "Erro ao incrementar views",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
+5 -7
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getListings } from "@/lib/prisma/queries/listings"; import { getListings } from "@/lib/prisma/queries/listings";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* GET /api/listings/my * GET /api/listings/my
@@ -35,8 +35,7 @@ export async function GET(request: NextRequest) {
pageSize: searchParams.get("pageSize") pageSize: searchParams.get("pageSize")
? parseInt(searchParams.get("pageSize")!, 10) ? parseInt(searchParams.get("pageSize")!, 10)
: 20, : 20,
sortBy: (searchParams.get("sortBy") || sortBy: (searchParams.get("sortBy") || "created_at") as
"created_at") as
| "created_at" | "created_at"
| "price_pix" | "price_pix"
| "price_tibia_coins" | "price_tibia_coins"
@@ -58,7 +57,7 @@ export async function GET(request: NextRequest) {
totalPages: Math.ceil(listingsData.total / filters.pageSize), totalPages: Math.ceil(listingsData.total / filters.pageSize),
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar anúncios do usuário", { secureLogger.error("❌ Erro ao buscar anúncios do usuário", {
@@ -71,8 +70,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Erro ao buscar anúncios", 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 { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { createListingSchema } from "@/lib/validation-schemas"; 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 * GET /api/listings
@@ -52,8 +52,7 @@ export async function GET(request: NextRequest) {
pageSize: searchParams.get("pageSize") pageSize: searchParams.get("pageSize")
? parseInt(searchParams.get("pageSize")!, 10) ? parseInt(searchParams.get("pageSize")!, 10)
: 20, : 20,
sortBy: (searchParams.get("sortBy") || sortBy: (searchParams.get("sortBy") || "created_at") as
"created_at") as
| "created_at" | "created_at"
| "price_pix" | "price_pix"
| "price_tibia_coins" | "price_tibia_coins"
@@ -73,13 +72,15 @@ export async function GET(request: NextRequest) {
const listingIds = listingsResponse.map((listing) => listing.listing_id); const listingIds = listingsResponse.map((listing) => listing.listing_id);
const pendingListingIds = await getPendingIntentionListingIdsForBuyer( const pendingListingIds = await getPendingIntentionListingIdsForBuyer(
auth.userId, auth.userId,
listingIds listingIds,
); );
const pendingSet = new Set(pendingListingIds); const pendingSet = new Set(pendingListingIds);
listingsResponse = listingsResponse.map((listing) => ({ listingsResponse = listingsResponse.map((listing) => ({
...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), totalPages: Math.ceil(listingsData.total / filters.pageSize),
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar anúncios", { secureLogger.error("❌ Erro ao buscar anúncios", {
@@ -107,7 +108,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Erro ao buscar anúncios", 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", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -172,7 +173,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: `Servidor "${validatedData.server_name}" não existe ou não está ativo`, 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 // 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 // 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 itemId = validatedData.item_id ?? validatedData.item_id_unified!;
const listing = await createListing({ const listing = await createListing({
seller_id: auth.userId, seller_id: auth.userId,
item_id: itemId, item_id: itemId,
@@ -210,7 +211,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
listing, listing,
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao criar anúncio", { secureLogger.error("❌ Erro ao criar anúncio", {
@@ -223,8 +224,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Erro ao criar anúncio", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { markAsRead } from "@/lib/prisma/queries/notifications"; import { markAsRead } from "@/lib/prisma/queries/notifications";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* POST /api/notifications/[id]/read * POST /api/notifications/[id]/read
@@ -9,7 +9,7 @@ import { markAsRead } from "@/lib/prisma/queries/notifications";
*/ */
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -18,10 +18,10 @@ export async function POST(
const { id } = await params; const { id } = await params;
const notificationId = parseInt(id, 10); const notificationId = parseInt(id, 10);
if (isNaN(notificationId)) { if (Number.isNaN(notificationId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de notificação inválido" }, { success: false, error: "ID de notificação inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -30,7 +30,7 @@ export async function POST(
if (!success) { if (!success) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Notificação não encontrada ou já está lida" }, { 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, success: true,
message: "Notificação marcada como lida", message: "Notificação marcada como lida",
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao marcar notificação como lida", { secureLogger.error("❌ Erro ao marcar notificação como lida", {
@@ -48,8 +48,7 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao marcar notificação como lida" }, { 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { import {
getUserNotifications,
getUnreadCount, getUnreadCount,
markAsRead, getUserNotifications,
markAllAsRead, markAllAsRead,
} from "@/lib/prisma/queries/notifications"; } from "@/lib/prisma/queries/notifications";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* GET /api/notifications * GET /api/notifications
@@ -25,7 +24,8 @@ export async function GET(request: NextRequest) {
: 50; : 50;
const notifications = await getUserNotifications(auth.userId, { 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, limit,
}); });
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
notifications, notifications,
unread_count: unreadCount, unread_count: unreadCount,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar notificações", { secureLogger.error("❌ Erro ao buscar notificações", {
@@ -47,7 +47,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar notificações" }, { 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`, message: `${count} notificações marcadas como lidas`,
count, count,
}, },
{ status: 200 } { status: 200 },
); );
} }
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Ação inválida" }, { success: false, error: "Ação inválida" },
{ status: 400 } { status: 400 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao marcar notificações como lidas", { secureLogger.error("❌ Erro ao marcar notificações como lidas", {
@@ -88,8 +88,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao marcar notificações como lidas" }, { 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 { requireAuth } from "@/lib/auth/middleware";
import { getUnreadCount } from "@/lib/prisma/queries/notifications"; import { getUnreadCount } from "@/lib/prisma/queries/notifications";
@@ -18,13 +18,12 @@ export async function GET(request: NextRequest) {
success: true, success: true,
count, count,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (_error) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Erro ao buscar contagem de notificações" }, { 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 * PATCH /api/purchase-intentions/[intentionId]/payment-method
* Permite ao comprador trocar o método de pagamento (máximo 1 vez) * 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 { requireAuth } from "@/lib/auth/middleware";
import { createSystemMessageInChat } from "@/lib/chat/system-messages";
import { getListingById } from "@/lib/prisma/queries/listings";
import { import {
getPurchaseIntentionById, getPurchaseIntentionById,
updatePurchaseIntentionPaymentMethod, updatePurchaseIntentionPaymentMethod,
} from "@/lib/prisma/queries/purchase-intentions"; } 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"; import { secureLogger } from "@/lib/secure-logger";
function getPaymentMethodLabel(method: string): string { function getPaymentMethodLabel(method: string): string {
@@ -29,7 +29,7 @@ function getPaymentMethodLabel(method: string): string {
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ intentionId: string }> } { params }: { params: Promise<{ intentionId: string }> },
) { ) {
try { try {
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
@@ -38,21 +38,24 @@ export async function PATCH(
const { intentionId: intentionIdParam } = await params; const { intentionId: intentionIdParam } = await params;
const intentionId = parseInt(intentionIdParam, 10); const intentionId = parseInt(intentionIdParam, 10);
if (isNaN(intentionId)) { if (Number.isNaN(intentionId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de intenção inválido" }, { success: false, error: "ID de intenção inválido" },
{ status: 400 } { status: 400 },
); );
} }
const body = await request.json(); const body = await request.json();
const { payment_method } = body; 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( return NextResponse.json(
{ success: false, error: "Método de pagamento inválido" }, { success: false, error: "Método de pagamento inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -61,39 +64,53 @@ export async function PATCH(
if (!intention) { if (!intention) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Intenção não encontrada" }, { success: false, error: "Intenção não encontrada" },
{ status: 404 } { status: 404 },
); );
} }
// Verificar se o usuário é o comprador // Verificar se o usuário é o comprador
if (intention.buyer_id !== auth.userId) { if (intention.buyer_id !== auth.userId) {
return NextResponse.json( 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 // Verificar se a intenção está pendente
if (intention.status !== "pending") { if (intention.status !== "pending") {
return NextResponse.json( 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 // Verificar se já atingiu o limite de trocas
if (intention.payment_method_change_count >= 1) { if (intention.payment_method_change_count >= 1) {
return NextResponse.json( 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 // Verificar se o novo método é diferente
if (intention.payment_method === payment_method) { if (intention.payment_method === payment_method) {
return NextResponse.json( 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) { if (!listing) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Anúncio não encontrado" }, { 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`, error: `Método de pagamento "${payment_method}" não está disponível para este anúncio`,
available_methods: availableMethods, available_methods: availableMethods,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -128,7 +145,7 @@ export async function PATCH(
const oldPaymentMethod = intention.payment_method; const oldPaymentMethod = intention.payment_method;
const updatedIntention = await updatePurchaseIntentionPaymentMethod( const updatedIntention = await updatePurchaseIntentionPaymentMethod(
intentionId, intentionId,
payment_method as any payment_method as any,
); );
// Criar mensagem do sistema no chat // Criar mensagem do sistema no chat
@@ -156,9 +173,10 @@ export async function PATCH(
{ {
success: true, success: true,
intention: updatedIntention, 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) { } catch (error) {
secureLogger.error("❌ Erro ao atualizar método de pagamento", { secureLogger.error("❌ Erro ao atualizar método de pagamento", {
@@ -167,9 +185,14 @@ export async function PATCH(
}); });
return NextResponse.json( 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getPurchaseIntentionsByBuyer } from "@/lib/prisma/queries/purchase-intentions";
import { prisma } from "@/lib/prisma/app"; import { prisma } from "@/lib/prisma/app";
import { getPurchaseIntentionsByBuyer } from "@/lib/prisma/queries/purchase-intentions";
import { trackEvent } from "@/lib/prisma/queries/telemetry"; import { trackEvent } from "@/lib/prisma/queries/telemetry";
import { secureLogger } from "@/lib/secure-logger";
/** /**
* GET /api/purchase-intentions/my * GET /api/purchase-intentions/my
@@ -78,9 +78,7 @@ export async function GET(request: NextRequest) {
// Buscar character names dos vendedores em batch // Buscar character names dos vendedores em batch
const sellerServerPairs = [ const sellerServerPairs = [
...new Set( ...new Set(listings.map((l) => `${l.seller_id}:${l.server_name}`)),
listings.map((l) => `${l.seller_id}:${l.server_name}`)
),
]; ];
const characterMap = new Map<string, string | null>(); const characterMap = new Map<string, string | null>();
@@ -98,7 +96,7 @@ export async function GET(request: NextRequest) {
}, },
}); });
characterMap.set(pair, character?.character_name || null); characterMap.set(pair, character?.character_name || null);
}) }),
); );
// Serializar datas para string // Serializar datas para string
@@ -123,8 +121,10 @@ export async function GET(request: NextRequest) {
return { return {
...intention, ...intention,
created_at: serializeDate(intention.created_at as Date) || intention.created_at, created_at:
updated_at: serializeDate(intention.updated_at as Date) || intention.updated_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), expires_at: serializeDate(intention.expires_at as Date | null),
accepted_at: serializeDate(intention.accepted_at as Date | null), accepted_at: serializeDate(intention.accepted_at as Date | null),
rejected_at: serializeDate(intention.rejected_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, seller_id: listing.seller_id,
price_pix: listing.price_pix ? Number(listing.price_pix) : null, price_pix: listing.price_pix ? Number(listing.price_pix) : null,
price_tibia_coins: 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) ? Number(listing.price_tibia_coins)
: null, : null,
price_gold_coins: listing.price_gold_coins price_gold_coins: listing.price_gold_coins
@@ -185,7 +186,7 @@ export async function GET(request: NextRequest) {
intentions: intentionsWithListingDetails, intentions: intentionsWithListingDetails,
count: intentionsWithListingDetails.length, count: intentionsWithListingDetails.length,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar intenções de compra do usuário", { 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( return NextResponse.json(
{ success: false, error: "Erro ao buscar intenções de compra" }, { 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 { getActiveServers } from "@/lib/prisma/queries/servers";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
@@ -6,7 +6,7 @@ import { secureLogger } from "@/lib/secure-logger";
* GET /api/servers * GET /api/servers
* Lista todos os servidores ativos disponíveis * Lista todos os servidores ativos disponíveis
*/ */
export async function GET(request: NextRequest) { export async function GET(_request: NextRequest) {
try { try {
const servers = await getActiveServers(); const servers = await getActiveServers();
@@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
}, },
})), })),
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar servidores", { secureLogger.error("❌ Erro ao buscar servidores", {
@@ -43,8 +43,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Erro ao buscar servidores", 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", 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 { requireAdminUser } from "@/lib/auth/middleware";
import { import {
countUsersInSubscriptionTier,
deleteSubscriptionTier,
getSubscriptionTierById, getSubscriptionTierById,
updateSubscriptionTier, updateSubscriptionTier,
deleteSubscriptionTier,
countUsersInSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers"; } from "@/lib/prisma/queries/subscription-tiers";
import { updateSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { updateSubscriptionTierSchema } from "@/lib/validation-schemas";
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
const auth = await requireAdminUser(request); const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) { if (auth instanceof NextResponse) {
@@ -25,7 +25,7 @@ export async function GET(
if (Number.isNaN(tierId) || tierId <= 0) { if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de tier inválido" }, { success: false, error: "ID de tier inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -34,7 +34,7 @@ export async function GET(
if (!tier) { if (!tier) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Plano não encontrado" }, { success: false, error: "Plano não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
@@ -43,7 +43,7 @@ export async function GET(
success: true, success: true,
tier, tier,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao consultar subscription tier", { secureLogger.error("❌ Erro ao consultar subscription tier", {
@@ -55,14 +55,14 @@ export async function GET(
success: false, success: false,
error: "Erro ao consultar plano de assinatura", error: "Erro ao consultar plano de assinatura",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
const auth = await requireAdminUser(request); const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) { if (auth instanceof NextResponse) {
@@ -76,7 +76,7 @@ export async function PATCH(
if (Number.isNaN(tierId) || tierId <= 0) { if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de tier inválido" }, { success: false, error: "ID de tier inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -105,7 +105,7 @@ export async function PATCH(
success: true, success: true,
tier: updatedTier, tier: updatedTier,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
if (error instanceof Error && "issues" in error) { if (error instanceof Error && "issues" in error) {
@@ -115,7 +115,7 @@ export async function PATCH(
error: "Dados inválidos", error: "Dados inválidos",
details: (error as any).issues, details: (error as any).issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -128,14 +128,14 @@ export async function PATCH(
success: false, success: false,
error: "Erro ao atualizar plano de assinatura", error: "Erro ao atualizar plano de assinatura",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
export async function DELETE( export async function DELETE(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
const auth = await requireAdminUser(request); const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) { if (auth instanceof NextResponse) {
@@ -149,7 +149,7 @@ export async function DELETE(
if (Number.isNaN(tierId) || tierId <= 0) { if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "ID de tier inválido" }, { success: false, error: "ID de tier inválido" },
{ status: 400 } { status: 400 },
); );
} }
@@ -158,7 +158,7 @@ export async function DELETE(
if (!tier) { if (!tier) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Plano não encontrado" }, { success: false, error: "Plano não encontrado" },
{ status: 404 } { status: 404 },
); );
} }
@@ -168,7 +168,7 @@ export async function DELETE(
success: false, success: false,
error: "O plano padrão 'free' não pode ser removido.", 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( return NextResponse.json(
{ {
success: false, 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, users: userCount,
}, },
{ status: 409 } { status: 409 },
); );
} }
@@ -191,7 +192,7 @@ export async function DELETE(
{ {
success: true, success: true,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao remover subscription tier", { secureLogger.error("❌ Erro ao remover subscription tier", {
@@ -203,8 +204,7 @@ export async function DELETE(
success: false, success: false,
error: "Erro ao remover plano de assinatura", 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 { requireAdminUser } from "@/lib/auth/middleware";
import { import {
assignUserToSubscriptionTier, assignUserToSubscriptionTier,
getDefaultSubscriptionTier,
getSubscriptionTierById, getSubscriptionTierById,
getSubscriptionTierBySlug, getSubscriptionTierBySlug,
getDefaultSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers"; } 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 { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
import { assignSubscriptionTierSchema } from "@/lib/validation-schemas";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request); const auth = await requireAdminUser(request);
@@ -27,19 +27,17 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Usuário não encontrado.", error: "Usuário não encontrado.",
}, },
{ status: 404 } { status: 404 },
); );
} }
let targetTierId: number | null = null; let targetTierId: number | null = null;
let targetTier: let targetTier: {
| { subscription_tier_id: number;
subscription_tier_id: number; name: string;
name: string; slug: string;
slug: string; level: number;
level: number; } | null = null;
}
| null = null;
if (parsed.tierId !== undefined) { if (parsed.tierId !== undefined) {
if (parsed.tierId === null) { if (parsed.tierId === null) {
@@ -50,7 +48,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Plano padrão não encontrado.", error: "Plano padrão não encontrado.",
}, },
{ status: 500 } { status: 500 },
); );
} }
targetTier = defaultTier; targetTier = defaultTier;
@@ -62,20 +60,23 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Plano informado não existe.", error: "Plano informado não existe.",
}, },
{ status: 404 } { status: 404 },
); );
} }
targetTier = tierById; targetTier = tierById;
} }
} else if (parsed.tierSlug) { } else if (parsed.tierSlug) {
const tierBySlug = await getSubscriptionTierBySlug(parsed.tierSlug, false); const tierBySlug = await getSubscriptionTierBySlug(
parsed.tierSlug,
false,
);
if (!tierBySlug) { if (!tierBySlug) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "Plano informado não existe.", error: "Plano informado não existe.",
}, },
{ status: 404 } { status: 404 },
); );
} }
targetTier = tierBySlug; targetTier = tierBySlug;
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Plano de assinatura não identificado.", error: "Plano de assinatura não identificado.",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -106,7 +107,7 @@ export async function POST(request: NextRequest) {
level: targetTier.level, level: targetTier.level,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
if (error instanceof Error && "issues" in error) { if (error instanceof Error && "issues" in error) {
@@ -116,7 +117,7 @@ export async function POST(request: NextRequest) {
error: "Dados inválidos", error: "Dados inválidos",
details: (error as any).issues, details: (error as any).issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -129,8 +130,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Erro ao atribuir plano de assinatura ao usuário", 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 { requireAdminUser } from "@/lib/auth/middleware";
import { import {
getAllSubscriptionTiers,
createSubscriptionTier, createSubscriptionTier,
getAllSubscriptionTiers,
} from "@/lib/prisma/queries/subscription-tiers"; } from "@/lib/prisma/queries/subscription-tiers";
import { createSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { createSubscriptionTierSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = await requireAdminUser(request); const auth = await requireAdminUser(request);
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
success: true, success: true,
tiers, tiers,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao listar subscription tiers", { secureLogger.error("❌ Erro ao listar subscription tiers", {
@@ -32,7 +32,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Erro ao listar planos de assinatura", error: "Erro ao listar planos de assinatura",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -68,7 +68,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
tier, tier,
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
if (error instanceof NextResponse) { if (error instanceof NextResponse) {
@@ -82,7 +82,7 @@ export async function POST(request: NextRequest) {
error: "Dados inválidos", error: "Dados inválidos",
details: (error as any).issues, details: (error as any).issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -95,8 +95,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Erro ao criar plano de assinatura", 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 { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { import {
telemetryEventSchema,
telemetryBatchSchema, telemetryBatchSchema,
telemetryEventSchema,
} from "@/lib/validation-schemas"; } from "@/lib/validation-schemas";
import { optionalAuth } from "@/lib/auth/middleware";
import {
trackEvent,
trackEventsBatch,
getAdAnalytics,
getMarketplaceStats,
} from "@/lib/prisma/queries/telemetry";
/** /**
* POST /api/telemetry/event * POST /api/telemetry/event
@@ -33,12 +33,14 @@ export async function POST(request: NextRequest) {
// Autenticação opcional (usuários anônimos podem gerar eventos) // Autenticação opcional (usuários anônimos podem gerar eventos)
const authResult = await optionalAuth(request); 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(); const body = await request.json();
// Verificar se é batch ou evento único // 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) { if (isBatch) {
// Batch de eventos // Batch de eventos
@@ -50,7 +52,7 @@ export async function POST(request: NextRequest) {
metadata: event.metadata || {}, metadata: event.metadata || {},
})), })),
}; };
const validationResult = telemetryBatchSchema.safeParse(normalizedBody); const validationResult = telemetryBatchSchema.safeParse(normalizedBody);
if (!validationResult.success) { if (!validationResult.success) {
@@ -60,7 +62,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: validationResult.error.issues, details: validationResult.error.issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
events: trackedEvents, events: trackedEvents,
}, },
{ status: 201 } { status: 201 },
); );
} else { } else {
// Evento único // Evento único
@@ -97,7 +99,7 @@ export async function POST(request: NextRequest) {
...body, ...body,
metadata: body.metadata || {}, metadata: body.metadata || {},
}; };
const validationResult = telemetryEventSchema.safeParse(normalizedBody); const validationResult = telemetryEventSchema.safeParse(normalizedBody);
if (!validationResult.success) { if (!validationResult.success) {
@@ -107,7 +109,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: validationResult.error.issues, details: validationResult.error.issues,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -134,7 +136,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
event: trackedEvent, event: trackedEvent,
}, },
{ status: 201 } { status: 201 },
); );
} }
} catch (error) { } catch (error) {
@@ -148,7 +150,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Erro ao registrar evento", error: "Erro ao registrar evento",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -178,7 +180,7 @@ export async function GET(request: NextRequest) {
success: true, success: true,
stats, stats,
}, },
{ status: 200 } { status: 200 },
); );
} }
@@ -186,13 +188,13 @@ export async function GET(request: NextRequest) {
if (adIdParam) { if (adIdParam) {
const adId = parseInt(adIdParam, 10); const adId = parseInt(adIdParam, 10);
if (isNaN(adId)) { if (Number.isNaN(adId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID do anúncio inválido", error: "ID do anúncio inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -204,7 +206,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Analytics não encontrado para este anúncio", 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, success: true,
analytics, analytics,
}, },
{ status: 200 } { status: 200 },
); );
} }
@@ -222,7 +224,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Parâmetro 'adId' ou 'dashboard' é obrigatório", error: "Parâmetro 'adId' ou 'dashboard' é obrigatório",
}, },
{ status: 400 } { status: 400 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar analytics", { secureLogger.error("❌ Erro ao buscar analytics", {
@@ -235,8 +237,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Erro ao buscar analytics", error: "Erro ao buscar analytics",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
+9 -10
View File
@@ -1,22 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { getUserById } from "@/lib/prisma/queries/users"; import { getUserById } from "@/lib/prisma/queries/users";
import { secureLogger } from "@/lib/secure-logger";
export async function GET( export async function GET(
request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const { id } = await params; const { id } = await params;
const userId = parseInt(id, 10); const userId = parseInt(id, 10);
if (isNaN(userId)) { if (Number.isNaN(userId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de usuário inválido", error: "ID de usuário inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -28,7 +28,7 @@ export async function GET(
success: false, success: false,
error: "Usuário não encontrado", error: "Usuário não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -46,7 +46,7 @@ export async function GET(
createdAt: user.created_at, createdAt: user.created_at,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
secureLogger.error("❌ Erro ao buscar perfil público", { secureLogger.error("❌ Erro ao buscar perfil público", {
@@ -60,8 +60,7 @@ export async function GET(
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { import {
updateCharacter,
deleteCharacter, deleteCharacter,
getCharacterById, updateCharacter,
} from "@/lib/prisma/queries/characters"; } from "@/lib/prisma/queries/characters";
import { secureLogger } from "@/lib/secure-logger";
import { characterSchema } from "@/lib/validation-schemas"; import { characterSchema } from "@/lib/validation-schemas";
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
const { id } = await params; const { id } = await params;
const characterId = parseInt(id, 10); const characterId = parseInt(id, 10);
if (isNaN(characterId)) { if (Number.isNaN(characterId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de personagem inválido", error: "ID de personagem inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -52,22 +51,18 @@ export async function PUT(
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
const validatedData = validationResult.data; const validatedData = validationResult.data;
// Atualizar personagem (verifica ownership internamente) // Atualizar personagem (verifica ownership internamente)
const updatedCharacter = await updateCharacter( const updatedCharacter = await updateCharacter(characterId, auth.userId, {
characterId, character_name: validatedData.characterName,
auth.userId, server_name: validatedData.serverName,
{ is_main: validatedData.isMain,
character_name: validatedData.characterName, });
server_name: validatedData.serverName,
is_main: validatedData.isMain,
}
);
secureLogger.info("✅ Personagem atualizado com sucesso", { secureLogger.info("✅ Personagem atualizado com sucesso", {
userId: auth.userId, userId: auth.userId,
@@ -87,7 +82,7 @@ export async function PUT(
createdAt: updatedCharacter.created_at, createdAt: updatedCharacter.created_at,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -96,16 +91,13 @@ export async function PUT(
} }
// Verificar se é erro de permissão // Verificar se é erro de permissão
if ( if (error instanceof Error && error.message.includes("não tem permissão")) {
error instanceof Error &&
error.message.includes("não tem permissão")
) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error.message, error: error.message,
}, },
{ status: 403 } { status: 403 },
); );
} }
@@ -120,35 +112,35 @@ export async function PUT(
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", error: error instanceof Error ? error.message : "Erro interno",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
export async function DELETE( export async function DELETE(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
const { id } = await params; const { id } = await params;
const characterId = parseInt(id, 10); const characterId = parseInt(id, 10);
if (isNaN(characterId)) { if (Number.isNaN(characterId)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "ID de personagem inválido", error: "ID de personagem inválido",
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -165,7 +157,7 @@ export async function DELETE(
{ {
success: true, success: true,
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -174,16 +166,13 @@ export async function DELETE(
} }
// Verificar se é erro de permissão // Verificar se é erro de permissão
if ( if (error instanceof Error && error.message.includes("não tem permissão")) {
error instanceof Error &&
error.message.includes("não tem permissão")
) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error.message, error: error.message,
}, },
{ status: 403 } { status: 403 },
); );
} }
@@ -198,8 +187,7 @@ export async function DELETE(
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; 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"; import { characterSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
// Listar personagens do usuário // Listar personagens do usuário
@@ -30,7 +33,7 @@ export async function GET(request: NextRequest) {
createdAt: char.created_at, createdAt: char.created_at,
})), })),
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -49,7 +52,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", error: error instanceof Error ? error.message : "Erro interno",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -58,11 +61,11 @@ export async function POST(request: NextRequest) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
const body = await request.json(); const body = await request.json();
@@ -82,7 +85,7 @@ export async function POST(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -113,7 +116,7 @@ export async function POST(request: NextRequest) {
createdAt: newCharacter.created_at, createdAt: newCharacter.created_at,
}, },
}, },
{ status: 201 } { status: 201 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -128,7 +131,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: "Este personagem já está cadastrado neste servidor", error: "Este personagem já está cadastrado neste servidor",
}, },
{ status: 409 } { status: 409 },
); );
} }
@@ -143,8 +146,7 @@ export async function POST(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { type NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware"; import { requireAuth } from "@/lib/auth/middleware";
import { getUserById, updateUser } from "@/lib/prisma/queries/users";
import { getUserCharacters } from "@/lib/prisma/queries/characters"; 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"; import { updateUserSchema } from "@/lib/validation-schemas";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
// Buscar dados do usuário // Buscar dados do usuário
@@ -25,7 +25,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: "Usuário não encontrado", error: "Usuário não encontrado",
}, },
{ status: 404 } { status: 404 },
); );
} }
@@ -61,7 +61,7 @@ export async function GET(request: NextRequest) {
createdAt: char.created_at, createdAt: char.created_at,
})), })),
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -80,7 +80,7 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", error: error instanceof Error ? error.message : "Erro interno",
}, },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -89,11 +89,11 @@ export async function PUT(request: NextRequest) {
try { try {
// Middleware de autenticação // Middleware de autenticação
const authResult = await requireAuth(request); const authResult = await requireAuth(request);
if (authResult instanceof NextResponse) { if (authResult instanceof NextResponse) {
return authResult; return authResult;
} }
const auth = authResult; const auth = authResult;
const body = await request.json(); const body = await request.json();
@@ -113,7 +113,7 @@ export async function PUT(request: NextRequest) {
error: "Dados de entrada inválidos", error: "Dados de entrada inválidos",
details: errors, details: errors,
}, },
{ status: 400 } { status: 400 },
); );
} }
@@ -153,7 +153,7 @@ export async function PUT(request: NextRequest) {
isVerified: updatedUser.is_verified, isVerified: updatedUser.is_verified,
}, },
}, },
{ status: 200 } { status: 200 },
); );
} catch (error) { } catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar // Se for NextResponse (erro de autenticação), re-lançar
@@ -172,8 +172,7 @@ export async function PUT(request: NextRequest) {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro interno", 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 { type NextRequest, NextResponse } from "next/server";
import { abacatePay } from "@/lib/abacatepay";
import { markAsPaid } from "@/lib/payment-cache"; import { markAsPaid } from "@/lib/payment-cache";
import { import {
checkRateLimit, checkRateLimit,
getClientIP,
createRateLimitResponse, createRateLimitResponse,
getClientIP,
} from "@/lib/rate-limiter"; } from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger"; 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 // Rejeitar eventos com mais de 5 minutos de diferença
if (timeDiff > 5 * 60 * 1000) { if (timeDiff > 5 * 60 * 1000) {
secureLogger.warn("Webhook rejeitado: timestamp inválido", { secureLogger.warn("Webhook rejeitado: timestamp inválido", {
timeDiff: timeDiff / 1000 + "s", timeDiff: `${timeDiff / 1000}s`,
}); });
return NextResponse.json( return NextResponse.json(
{ error: "Timestamp inválido" }, { error: "Timestamp inválido" },
{ status: 400 } { status: 400 },
); );
} }
} }
@@ -119,7 +118,7 @@ export async function POST(request: NextRequest) {
secureLogger.info( secureLogger.info(
"Registro de pagamento criado", "Registro de pagamento criado",
secureLogger.mask(donationRecord) secureLogger.mask(donationRecord),
); );
// Salvar no cache para frontend detectar via polling // Salvar no cache para frontend detectar via polling
@@ -150,7 +149,7 @@ export async function POST(request: NextRequest) {
secureLogger.error("Webhook error", error); secureLogger.error("Webhook error", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao processar webhook" }, { error: "Erro ao processar webhook" },
{ status: 500 } { status: 500 },
); );
} }
} }
+11 -5
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next"; 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"; import "./globals.css";
const nunitoSans = Nunito_Sans({ const nunitoSans = Nunito_Sans({
@@ -15,7 +15,8 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tibia Trade", 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 = { export const viewport: Viewport = {
@@ -24,13 +25,18 @@ export const viewport: Viewport = {
initialScale: 1, initialScale: 1,
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="pt" suppressHydrationWarning> <html lang="pt" suppressHydrationWarning>
<body className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}> <body
className={`${nunitoSans.variable} ${geistMono.variable} antialiased`}
>
{children} {children}
</body> </body>
</html> </html>
); );
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://tibiatrade.evandro.dev.br"; const baseUrl = "https://tibiatrade.evandro.dev.br";
+35 -29
View File
@@ -1,20 +1,11 @@
"use client"; "use client";
import { import { LogOut, User } from "lucide-react";
NavigationMenu, import Link from "next/link";
NavigationMenuItem, import { useLocale, useTranslations } from "next-intl";
NavigationMenuLink, import { useEffect, useState } from "react";
NavigationMenuList, import { Avatar, AvatarFallback } from "@/components/ui/avatar";
navigationMenuTriggerStyle, import { Badge } from "@/components/ui/badge";
} 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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -24,14 +15,23 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import {
import { cn } from "@/lib/utils"; NavigationMenu,
import { useTranslations, useLocale } from "next-intl"; NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { WordRotate } from "@/components/ui/word-rotate";
import { useSession } from "@/lib/hooks/use-session"; import { useSession } from "@/lib/hooks/use-session";
import Link from "next/link"; import { cn } from "@/lib/utils";
import { useState, useEffect } from "react"; import { LoginDialog } from "./auth/login-dialog";
import { LogOut, User } from "lucide-react"; import { RegisterDialog } from "./auth/register-dialog";
import { Badge } from "@/components/ui/badge"; 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() { export default function Header() {
const t = useTranslations("header.nav"); const t = useTranslations("header.nav");
@@ -90,7 +90,7 @@ export default function Header() {
navigationMenuTriggerStyle(), navigationMenuTriggerStyle(),
"bg-transparent", "bg-transparent",
highlight && highlight &&
"bg-primary text-primary-foreground hover:bg-primary/90" "bg-primary text-primary-foreground hover:bg-primary/90",
)} )}
> >
{label} {label}
@@ -105,10 +105,10 @@ export default function Header() {
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
<LanguageSwitcher /> <LanguageSwitcher />
{/* Messages */} {/* Messages */}
{mounted && isAuthenticated && <MessagesBell />} {mounted && isAuthenticated && <MessagesBell />}
{/* Notifications */} {/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />} {mounted && isAuthenticated && <NotificationBell />}
{mounted && isAuthenticated && user?.subscriptionTier && ( {mounted && isAuthenticated && user?.subscriptionTier && (
@@ -116,7 +116,7 @@ export default function Header() {
{user.subscriptionTier.slug} {user.subscriptionTier.slug}
</Badge> </Badge>
)} )}
{/* Auth Section */} {/* Auth Section */}
{!mounted ? ( {!mounted ? (
// Placeholder durante SSR para evitar mismatch // Placeholder durante SSR para evitar mismatch
@@ -126,7 +126,10 @@ export default function Header() {
) : isAuthenticated && user ? ( ) : isAuthenticated && user ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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"> <Avatar className="h-8 w-8">
<AvatarFallback> <AvatarFallback>
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
@@ -191,9 +194,12 @@ export default function Header() {
{/* Login Dialog */} {/* Login Dialog */}
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} /> <LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
{/* Register Dialog */} {/* Register Dialog */}
<RegisterDialog open={registerDialogOpen} onOpenChange={setRegisterDialogOpen} /> <RegisterDialog
open={registerDialogOpen}
onOpenChange={setRegisterDialogOpen}
/>
</header> </header>
); );
} }
+25 -15
View File
@@ -1,10 +1,13 @@
"use client"; "use client";
import { useState } from "react"; 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 { 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 { interface AdminPanelTabsProps {
adminUser: { adminUser: {
@@ -28,9 +31,9 @@ export function AdminPanelTabs({
locale, locale,
initialTab = "overview", initialTab = "overview",
}: AdminPanelTabsProps) { }: AdminPanelTabsProps) {
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">( const [activeTab, setActiveTab] = useState<
initialTab "overview" | "subscriptions" | "items"
); >(initialTab);
return ( return (
<Tabs <Tabs
@@ -40,31 +43,39 @@ export function AdminPanelTabs({
> >
<TabsList> <TabsList>
<TabsTrigger value="overview">Visão Geral</TabsTrigger> <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> <TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="overview" className="space-y-4"> <TabsContent value="overview" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Bem-vindo, {adminUser?.username ?? "Administrador"}</CardTitle> <CardTitle>
Bem-vindo, {adminUser?.username ?? "Administrador"}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground"> <CardContent className="space-y-3 text-sm text-muted-foreground">
<p> <p>
Utilize as abas acima para administrar planos de assinatura, permissões Utilize as abas acima para administrar planos de assinatura,
e a curadoria de itens do marketplace. Esta área é exclusiva para permissões e a curadoria de itens do marketplace. Esta área é
operadores autorizados. exclusiva para operadores autorizados.
</p> </p>
<p> <p>
Para solicitações críticas, lembre-se de registrar mudanças e validar Para solicitações críticas, lembre-se de registrar mudanças e
com a equipe responsável antes de disponibilizar aos usuários finais. validar com a equipe responsável antes de disponibilizar aos
usuários finais.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="subscriptions"> <TabsContent value="subscriptions">
<AdminSubscriptionTab initialTiers={initialTiers} adminUser={adminUser} /> <AdminSubscriptionTab
initialTiers={initialTiers}
adminUser={adminUser}
/>
</TabsContent> </TabsContent>
<TabsContent value="items"> <TabsContent value="items">
@@ -73,4 +84,3 @@ export function AdminPanelTabs({
</Tabs> </Tabs>
); );
} }
+2 -3
View File
@@ -27,8 +27,8 @@ export function AdminSubscriptionTab({
</h2> </h2>
<p className="text-sm text-muted-foreground max-w-2xl"> <p className="text-sm text-muted-foreground max-w-2xl">
Configure planos, recursos e vincule usuários às assinaturas Configure planos, recursos e vincule usuários às assinaturas
disponíveis. Todas as alterações são aplicadas imediatamente disponíveis. Todas as alterações são aplicadas imediatamente após
após salvar. salvar.
</p> </p>
{adminUser?.subscription_tier && ( {adminUser?.subscription_tier && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -47,4 +47,3 @@ export function AdminSubscriptionTab({
</div> </div>
); );
} }
+18 -8
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm"; import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant"; import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant";
@@ -13,7 +13,11 @@ interface AdminItemCreatePageProps {
serverOptions: string[]; serverOptions: string[];
} }
export function AdminItemCreatePage({ locale, categories, serverOptions }: AdminItemCreatePageProps) { export function AdminItemCreatePage({
locale,
categories,
serverOptions,
}: AdminItemCreatePageProps) {
const router = useRouter(); const router = useRouter();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -54,14 +58,20 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
}); });
if (!response.ok) { 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"); throw new Error(data?.error || data?.message || "Falha ao criar item");
} }
goBackToList(); goBackToList();
} catch (err) { } catch (err) {
console.error(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 { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -73,8 +83,9 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Novo item</h1> <h1 className="text-3xl font-bold tracking-tight">Novo item</h1>
<p className="text-muted-foreground max-w-2xl"> <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. Utilize a seção de IA abaixo para gerar automaticamente campos
Ajuste o que for necessário antes de publicar. sugeridos a partir de prints e descrições. Ajuste o que for
necessário antes de publicar.
</p> </p>
</div> </div>
<Button variant="outline" onClick={goBackToList} className="gap-2"> <Button variant="outline" onClick={goBackToList} className="gap-2">
@@ -120,4 +131,3 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react";
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm"; import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
interface AdminItemCreateViewProps { interface AdminItemCreateViewProps {
@@ -10,7 +10,11 @@ interface AdminItemCreateViewProps {
serverOptions: string[]; serverOptions: string[];
} }
export function AdminItemCreateView({ locale, categories, serverOptions }: AdminItemCreateViewProps) { export function AdminItemCreateView({
locale,
categories,
serverOptions,
}: AdminItemCreateViewProps) {
const router = useRouter(); const router = useRouter();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -43,7 +47,9 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
}); });
if (!response.ok) { 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"); throw new Error(errorPayload?.error || "Falha ao criar item");
} }
@@ -75,4 +81,3 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
+197 -58
View File
@@ -1,19 +1,34 @@
"use client"; "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 { format } from "date-fns";
import { ptBR } from "date-fns/locale"; 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 { export interface AdminItemRecord {
item_id: number; item_id: number;
@@ -50,12 +65,20 @@ interface AdminItemManagerProps {
locale: string; locale: string;
} }
export function AdminItemManager({ initialData, locale }: AdminItemManagerProps) { export function AdminItemManager({
initialData,
locale,
}: AdminItemManagerProps) {
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<AdminItemsResponse>(initialData); const [data, setData] = useState<AdminItemsResponse>(initialData);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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 [serverFilter, setServerFilter] = useState<string>("all");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
@@ -65,10 +88,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const pageSize = initialData.pageSize ?? 50; const pageSize = initialData.pageSize ?? 50;
const [modalOpen, setModalOpen] = useState(false); 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 [isSubmitting, setIsSubmitting] = useState(false);
const [activeServers, setActiveServers] = useState<string[]>([]); const [activeServers, setActiveServers] = useState<string[]>([]);
const [serversLoading, setServersLoading] = useState<boolean>(false); const [_serversLoading, setServersLoading] = useState<boolean>(false);
const searchDebounce = useRef<number | undefined>(undefined); const searchDebounce = useRef<number | undefined>(undefined);
@@ -96,13 +121,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status); console.warn(
"[AdminItemManager] falha ao buscar servidores ativos:",
res.status,
);
return; return;
} }
const json = await res.json(); const json = await res.json();
const names: string[] = Array.isArray(json?.servers) const names: string[] = Array.isArray(json?.servers)
? 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)) .filter((n: string | null): n is string => Boolean(n))
: []; : [];
if (!cancelled) { if (!cancelled) {
@@ -147,8 +177,19 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
signal: controller.signal, signal: controller.signal,
}); });
const durationMs = Math.round(performance.now() - start); const durationMs = Math.round(performance.now() - start);
setApiStatus({ code: res.status, ok: res.ok, durationMs, checkedAt: new Date().toISOString() }); setApiStatus({
console.log("Status:", res.status, res.statusText, "Duration:", `${durationMs}ms`); code: res.status,
ok: res.ok,
durationMs,
checkedAt: new Date().toISOString(),
});
console.log(
"Status:",
res.status,
res.statusText,
"Duration:",
`${durationMs}ms`,
);
console.log("Headers:", { console.log("Headers:", {
"content-type": res.headers.get("content-type"), "content-type": res.headers.get("content-type"),
"x-powered-by": res.headers.get("x-powered-by"), "x-powered-by": res.headers.get("x-powered-by"),
@@ -165,10 +206,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
res.status === 401 res.status === 401
? "Sessão expirada ou não autenticado. Faça login e tente novamente." ? "Sessão expirada ou não autenticado. Faça login e tente novamente."
: res.status === 403 : res.status === 403
? "Acesso restrito ao administrador." ? "Acesso restrito ao administrador."
: "Não foi possível carregar os itens."; : "Não foi possível carregar os itens.";
const apiMsg = errorPayload?.error || errorPayload?.message || null; 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); console.error("Erro na API:", composed, "Payload:", errorPayload);
throw new Error(composed); throw new Error(composed);
} }
@@ -179,23 +222,42 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
throw new Error("Resposta inválida da API de itens."); throw new Error("Resposta inválida da API de itens.");
} }
setData(json); 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) { } catch (err: any) {
const durationMs = Math.round(performance.now() - start); 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") { if (err?.name === "AbortError") {
console.error("Requisição abortada por timeout"); console.error("Requisição abortada por timeout");
setError("Tempo de resposta excedido. Tente novamente."); setError("Tempo de resposta excedido. Tente novamente.");
} else { } else {
console.error("Falha ao carregar itens:", err); 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 { } finally {
console.groupEnd(); console.groupEnd();
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
setLoading(false); setLoading(false);
} }
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]); }, [
search,
serverFilter,
categoryFilter,
rarityFilter,
page,
pageSize,
isAdminItemsResponse,
]);
useEffect(() => { useEffect(() => {
if (searchDebounce.current !== undefined) { if (searchDebounce.current !== undefined) {
@@ -219,7 +281,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
set.add(item.rarity); 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]); }, [data.items]);
const openEditModal = (item: AdminItemRecord) => { const openEditModal = (item: AdminItemRecord) => {
@@ -257,8 +321,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Erro desconhecido" })); const error = await res
throw new Error(error?.error || error?.message || "Falha ao salvar item"); .json()
.catch(() => ({ error: "Erro desconhecido" }));
throw new Error(
error?.error || error?.message || "Falha ao salvar item",
);
} }
await fetchItems(); await fetchItems();
@@ -281,12 +349,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<div className="space-y-1"> <div className="space-y-1">
<CardTitle>Todos os itens cadastrados</CardTitle> <CardTitle>Todos os itens cadastrados</CardTitle>
<CardDescription> <CardDescription>
Visualize e edite os registros unificados importados do database do Tibia Wiki. Visualize e edite os registros unificados importados do database
Itens específicos de servidores podem ser marcados através das tags. do Tibia Wiki. Itens específicos de servidores podem ser marcados
através das tags.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-wrap gap-2"> <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 <RefreshCw className="mr-2 h-4 w-4" /> Atualizar
</Button> </Button>
<Button <Button
@@ -309,7 +383,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} 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> <SelectTrigger>
<SelectValue placeholder="Categoria" /> <SelectValue placeholder="Categoria" />
</SelectTrigger> </SelectTrigger>
@@ -322,7 +402,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={rarityFilter} onValueChange={(value) => { setRarityFilter(value); setPage(1); }}> <Select
value={rarityFilter}
onValueChange={(value) => {
setRarityFilter(value);
setPage(1);
}}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Raridade" /> <SelectValue placeholder="Raridade" />
</SelectTrigger> </SelectTrigger>
@@ -340,7 +426,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{apiStatus.code !== null && ( {apiStatus.code !== null && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"} API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""} {apiStatus.durationMs !== null
? ` • ${apiStatus.durationMs}ms`
: ""}
</Badge> </Badge>
)} )}
</div> </div>
@@ -350,7 +438,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{error && ( {error && (
<Card className="border-destructive/40 bg-destructive/10"> <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> </Card>
)} )}
@@ -382,26 +472,48 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<table className="w-full table-auto border-collapse min-w-[960px]"> <table className="w-full table-auto border-collapse min-w-[960px]">
<thead> <thead>
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide"> <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">
<th className="border border-border px-3 py-2 text-left">Nome</th> Imagem
<th className="border border-border px-3 py-2 text-left">Categoria</th> </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">
<th className="border border-border px-3 py-2 text-left">Origem</th> Nome
<th className="border border-border px-3 py-2 text-left">Servidores</th> </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">
<th className="border border-border px-3 py-2 text-left">Ações</th> 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> </tr>
</thead> </thead>
<tbody> <tbody>
{itemsForDisplay.length === 0 ? ( {itemsForDisplay.length === 0 ? (
<tr> <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. Nenhum item encontrado com os filtros atuais.
</td> </td>
</tr> </tr>
) : ( ) : (
itemsForDisplay.map((item) => ( 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"> <td className="border border-border px-3 py-2 align-middle">
{item.image_url ? ( {item.image_url ? (
<div className="relative h-12 w-12 overflow-hidden rounded border"> <div className="relative h-12 w-12 overflow-hidden rounded border">
@@ -413,7 +525,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
/> />
</div> </div>
) : ( ) : (
<span className="text-xs text-muted-foreground">Sem imagem</span> <span className="text-xs text-muted-foreground">
Sem imagem
</span>
)} )}
</td> </td>
<td className="border border-border px-3 py-2 align-top"> <td className="border border-border px-3 py-2 align-top">
@@ -422,14 +536,21 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
#{item.item_id} #{item.item_id}
</div> </div>
</td> </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"> <td className="border border-border px-3 py-2 align-top text-sm">
{item.rarity ? ( {item.rarity ? (
<Badge variant="outline" className="uppercase text-[10px]"> <Badge
variant="outline"
className="uppercase text-[10px]"
>
{item.rarity} {item.rarity}
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground text-xs">-</span> <span className="text-muted-foreground text-xs">
-
</span>
)} )}
</td> </td>
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground"> <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 ? ( {item.servers.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{item.servers.map((serverTag) => ( {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} {serverTag}
</Badge> </Badge>
))} ))}
</div> </div>
) : ( ) : (
<span className="text-muted-foreground text-xs">Global</span> <span className="text-muted-foreground text-xs">
Global
</span>
)} )}
</td> </td>
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground"> <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>
<td className="border border-border px-3 py-2 align-top"> <td className="border border-border px-3 py-2 align-top">
<Button <Button
@@ -471,7 +602,8 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span> <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> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -485,8 +617,16 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setPage((prev) => (prev >= Math.ceil(data.total / pageSize) ? prev : prev + 1))} onClick={() =>
disabled={page >= Math.ceil(data.total / pageSize) || loading} setPage((prev) =>
prev >= Math.ceil(data.total / pageSize)
? prev
: prev + 1,
)
}
disabled={
page >= Math.ceil(data.total / pageSize) || loading
}
> >
Próxima Próxima
</Button> </Button>
@@ -510,4 +650,3 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
</div> </div>
); );
} }
+99 -33
View File
@@ -1,13 +1,19 @@
"use client"; "use client";
import { Loader2, Sparkles } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input";
import { ItemImageCapture, ProcessedImage } from "./ItemImageCapture"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ItemImageCapture, type ProcessedImage } from "./ItemImageCapture";
interface ItemAiAssistantProps { interface ItemAiAssistantProps {
locale: string; locale: string;
@@ -86,7 +92,10 @@ export function ItemAiAssistant({
const data = await response.json(); const data = await response.json();
const structure = data?.structure ?? data; const structure = data?.structure ?? data;
const columns = structure?.columns; 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 // Heurística: sugerir nomes com base em imagens quando não houver texto detectado
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => { const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
@@ -96,7 +105,15 @@ export function ItemAiAssistant({
.replace(/\.[a-zA-Z0-9]+$/, "") .replace(/\.[a-zA-Z0-9]+$/, "")
.toLowerCase() .toLowerCase()
.replace(/[_\s]+/g, "-"); .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 orientation = first.height > first.width ? "retrato" : "paisagem";
const suggestions: string[] = []; const suggestions: string[] = [];
if (base && !common.has(base)) suggestions.push(base); if (base && !common.has(base)) suggestions.push(base);
@@ -105,12 +122,19 @@ export function ItemAiAssistant({
return Array.from(new Set(suggestions)).slice(0, 3); return Array.from(new Set(suggestions)).slice(0, 3);
}; };
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim()); const autoNameDetected =
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim()); 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 // Estrutura final com campos obrigatórios, origem e sugestões
const finalStructure = { const finalStructure = {
@@ -125,18 +149,35 @@ export function ItemAiAssistant({
? "Confirme o nome sugerido ou ajuste se necessário." ? "Confirme o nome sugerido ou ajuste se necessário."
: "Informe um nome para o item. Descrição é opcional, mas ajuda.", : "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)); setRawJson(JSON.stringify(finalStructure, null, 2));
const prefill: ItemAiPrefill = { const prefill: ItemAiPrefill = {
name: autoNameDetected ? (columns!.name as string) : undefined, name: autoNameDetected ? (columns?.name as string) : undefined,
description: autoDescDetected ? (columns!.description as string) : undefined, description: autoDescDetected
metadata: typeof columns === "object" && columns !== null ? (columns?.description as string)
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined } : undefined,
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : 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) { 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 }); onGenerated({ json: finalStructure, prefill });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA."); setError(
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]); err instanceof Error ? err.message : "Erro ao processar análise de IA.",
);
setLogs((prev) => [
...prev,
`Erro: ${err instanceof Error ? err.message : String(err)}`,
]);
} finally { } finally {
setLoading(false); setLoading(false);
setLogs((prev) => [...prev, "Processo de geração concluído."]); setLogs((prev) => [...prev, "Processo de geração concluído."]);
@@ -193,8 +242,8 @@ export function ItemAiAssistant({
Assistente IA para registro de item Assistente IA para registro de item
</h2> </h2>
<p className="max-w-3xl text-sm text-muted-foreground"> <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 Cole prints e descreva o item. A IA vai sugerir a tabela, campos e
antes de salvar. metadados. Você pode ajustar os dados antes de salvar.
</p> </p>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -216,7 +265,10 @@ export function ItemAiAssistant({
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Escopo</label> <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> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -247,7 +299,9 @@ export function ItemAiAssistant({
)} )}
<div className="space-y-2"> <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 <Textarea
value={description} value={description}
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
@@ -257,10 +311,14 @@ export function ItemAiAssistant({
</div> </div>
<div className="space-y-2"> <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} /> <ItemImageCapture onImagesChange={setImages} />
{images.length > 0 && ( {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> </div>
@@ -271,12 +329,17 @@ export function ItemAiAssistant({
)} )}
<div className="flex flex-wrap gap-2"> <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" />} {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gerar com IA Gerar com IA
</Button> </Button>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
@@ -292,16 +355,20 @@ export function ItemAiAssistant({
</pre> </pre>
) : ( ) : (
<p> <p>
Execute a análise para visualizar o JSON sugerido. Ele será usado para preencher automaticamente os Execute a análise para visualizar o JSON sugerido. Ele será
campos do formulário. usado para preencher automaticamente os campos do formulário.
</p> </p>
)} )}
{logs.length > 0 && ( {logs.length > 0 && (
<div className="space-y-1"> <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"> <div className="rounded-md border bg-muted/30 p-2">
{logs.map((line, idx) => ( {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>
</div> </div>
@@ -312,4 +379,3 @@ export function ItemAiAssistant({
</section> </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"; "use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { Upload, X } from "lucide-react"; import { Upload, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { resizeImageToDataURL } from "@/lib/image/resize"; import { resizeImageToDataURL } from "@/lib/image/resize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -225,4 +225,3 @@ export function ItemImageCapture({
</div> </div>
); );
} }
+108 -37
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
type FeedbackState = type FeedbackState =
| { type: "success"; message: string } | { type: "success"; message: string }
@@ -38,7 +38,10 @@ function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState {
priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "", priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "",
isActive: tier.is_active, isActive: tier.is_active,
features: (tier.features ?? []) features: (tier.features ?? [])
.map((feature) => `${feature.feature_key}|${feature.label}|${feature.description ?? ""}`) .map(
(feature) =>
`${feature.feature_key}|${feature.label}|${feature.description ?? ""}`,
)
.join("\n"), .join("\n"),
}; };
} }
@@ -53,7 +56,9 @@ function parseFeatures(text: string) {
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.map((line, index) => { .map((line, index) => {
const [featureKey, label, description] = line.split("|").map((part) => part?.trim() ?? ""); const [featureKey, label, description] = line
.split("|")
.map((part) => part?.trim() ?? "");
return { return {
feature_key: featureKey, feature_key: featureKey,
label: label || featureKey, label: label || featureKey,
@@ -79,8 +84,14 @@ export function AdminSubscriptionDashboard({
isActive: true, isActive: true,
features: "", features: "",
}); });
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(() => const [editForms, setEditForms] = useState<Record<number, TierFormState>>(
Object.fromEntries(initialTiers.map((tier) => [tier.subscription_tier_id, mapTierToFormState(tier)])) () =>
Object.fromEntries(
initialTiers.map((tier) => [
tier.subscription_tier_id,
mapTierToFormState(tier),
]),
),
); );
const [assignUserId, setAssignUserId] = useState(""); const [assignUserId, setAssignUserId] = useState("");
const [assignTierId, setAssignTierId] = useState<string>("free"); const [assignTierId, setAssignTierId] = useState<string>("free");
@@ -105,8 +116,8 @@ export function AdminSubscriptionDashboard({
data.tiers.map((tier: SubscriptionTierSummary) => [ data.tiers.map((tier: SubscriptionTierSummary) => [
tier.subscription_tier_id, tier.subscription_tier_id,
mapTierToFormState(tier), mapTierToFormState(tier),
]) ]),
) ),
); );
}, []); }, []);
@@ -119,8 +130,12 @@ export function AdminSubscriptionDashboard({
slug: createForm.slug, slug: createForm.slug,
description: createForm.description || null, description: createForm.description || null,
level: Number.parseInt(createForm.level.toString(), 10), level: Number.parseInt(createForm.level.toString(), 10),
priceMonthly: createForm.priceMonthly ? Number.parseFloat(createForm.priceMonthly) : null, priceMonthly: createForm.priceMonthly
priceYearly: createForm.priceYearly ? Number.parseFloat(createForm.priceYearly) : null, ? Number.parseFloat(createForm.priceMonthly)
: null,
priceYearly: createForm.priceYearly
? Number.parseFloat(createForm.priceYearly)
: null,
isActive: createForm.isActive, isActive: createForm.isActive,
features: parseFeatures(createForm.features), features: parseFeatures(createForm.features),
}; };
@@ -156,7 +171,9 @@ export function AdminSubscriptionDashboard({
setFeedback({ setFeedback({
type: "error", type: "error",
message: 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 { } finally {
setIsLoading(false); setIsLoading(false);
@@ -173,8 +190,12 @@ export function AdminSubscriptionDashboard({
slug: form.slug, slug: form.slug,
description: form.description || null, description: form.description || null,
level: Number.parseInt(form.level.toString(), 10), level: Number.parseInt(form.level.toString(), 10),
priceMonthly: form.priceMonthly ? Number.parseFloat(form.priceMonthly) : null, priceMonthly: form.priceMonthly
priceYearly: form.priceYearly ? Number.parseFloat(form.priceYearly) : null, ? Number.parseFloat(form.priceMonthly)
: null,
priceYearly: form.priceYearly
? Number.parseFloat(form.priceYearly)
: null,
isActive: form.isActive, isActive: form.isActive,
features: parseFeatures(form.features), features: parseFeatures(form.features),
}; };
@@ -194,12 +215,17 @@ export function AdminSubscriptionDashboard({
} }
await refreshTiers(); await refreshTiers();
setFeedback({ type: "success", message: "Plano atualizado com sucesso." }); setFeedback({
type: "success",
message: "Plano atualizado com sucesso.",
});
} catch (error) { } catch (error) {
setFeedback({ setFeedback({
type: "error", type: "error",
message: 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 { } finally {
setIsLoading(false); setIsLoading(false);
@@ -231,7 +257,9 @@ export function AdminSubscriptionDashboard({
setFeedback({ setFeedback({
type: "error", type: "error",
message: 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 { } finally {
setIsLoading(false); setIsLoading(false);
@@ -242,7 +270,10 @@ export function AdminSubscriptionDashboard({
resetFeedback(); resetFeedback();
if (!assignUserId.trim()) { 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; return;
} }
@@ -290,7 +321,7 @@ export function AdminSubscriptionDashboard({
} }
return a.level - b.level; return a.level - b.level;
}), }),
[tiers] [tiers],
); );
return ( return (
@@ -322,7 +353,10 @@ export function AdminSubscriptionDashboard({
placeholder="Ex.: Ultra" placeholder="Ex.: Ultra"
value={createForm.name} value={createForm.name}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ ...prev, name: event.target.value })) setCreateForm((prev) => ({
...prev,
name: event.target.value,
}))
} }
/> />
</div> </div>
@@ -335,7 +369,10 @@ export function AdminSubscriptionDashboard({
placeholder="ex.: ultra" placeholder="ex.: ultra"
value={createForm.slug} value={createForm.slug}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ ...prev, slug: event.target.value })) setCreateForm((prev) => ({
...prev,
slug: event.target.value,
}))
} }
/> />
</div> </div>
@@ -365,7 +402,10 @@ export function AdminSubscriptionDashboard({
variant={createForm.isActive ? "default" : "outline"} variant={createForm.isActive ? "default" : "outline"}
type="button" type="button"
onClick={() => onClick={() =>
setCreateForm((prev) => ({ ...prev, isActive: !prev.isActive })) setCreateForm((prev) => ({
...prev,
isActive: !prev.isActive,
}))
} }
> >
{createForm.isActive ? "Ativo" : "Inativo"} {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="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-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) Preço mensal (opcional)
</label> </label>
<Input <Input
@@ -385,12 +428,18 @@ export function AdminSubscriptionDashboard({
step="0.01" step="0.01"
value={createForm.priceMonthly} value={createForm.priceMonthly}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceMonthly: event.target.value })) setCreateForm((prev) => ({
...prev,
priceMonthly: event.target.value,
}))
} }
/> />
</div> </div>
<div className="space-y-2"> <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) Preço anual (opcional)
</label> </label>
<Input <Input
@@ -400,7 +449,10 @@ export function AdminSubscriptionDashboard({
step="0.01" step="0.01"
value={createForm.priceYearly} value={createForm.priceYearly}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceYearly: event.target.value })) setCreateForm((prev) => ({
...prev,
priceYearly: event.target.value,
}))
} }
/> />
</div> </div>
@@ -415,7 +467,10 @@ export function AdminSubscriptionDashboard({
rows={3} rows={3}
value={createForm.description} value={createForm.description}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ ...prev, description: event.target.value })) setCreateForm((prev) => ({
...prev,
description: event.target.value,
}))
} }
/> />
</div> </div>
@@ -429,7 +484,10 @@ export function AdminSubscriptionDashboard({
rows={4} rows={4}
value={createForm.features} value={createForm.features}
onChange={(event) => 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" placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
/> />
@@ -452,7 +510,8 @@ export function AdminSubscriptionDashboard({
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{orderedTiers.map((tier) => { {orderedTiers.map((tier) => {
const formState = editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier); const formState =
editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
return ( return (
<Card key={tier.subscription_tier_id} className="border-muted"> <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"> <CardTitle className="flex items-center gap-2 text-xl">
{tier.name} {tier.name}
<Badge variant="secondary">{tier.slug}</Badge> <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"} {tier.is_active ? "Ativo" : "Inativo"}
</Badge> </Badge>
</CardTitle> </CardTitle>
@@ -514,7 +576,10 @@ export function AdminSubscriptionDashboard({
...prev, ...prev,
[tier.subscription_tier_id]: { [tier.subscription_tier_id]: {
...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, ...prev,
[tier.subscription_tier_id]: { [tier.subscription_tier_id]: {
...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="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-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 <Input
type="number" type="number"
min={0} min={0}
@@ -618,7 +686,9 @@ export function AdminSubscriptionDashboard({
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
type="button" type="button"
onClick={() => handleUpdateTier(tier.subscription_tier_id)} onClick={() =>
handleUpdateTier(tier.subscription_tier_id)
}
disabled={isLoading} disabled={isLoading}
> >
Salvar alterações Salvar alterações
@@ -640,7 +710,9 @@ export function AdminSubscriptionDashboard({
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => handleDeleteTier(tier.subscription_tier_id, tier.slug)} onClick={() =>
handleDeleteTier(tier.subscription_tier_id, tier.slug)
}
disabled={isLoading} disabled={isLoading}
> >
Remover plano Remover plano
@@ -654,7 +726,7 @@ export function AdminSubscriptionDashboard({
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Vincular plano a usuário</CardTitle> <CardTitle>Vincular plano a usuário</CardTitle>
</CardHeader> </CardHeader>
@@ -698,4 +770,3 @@ export function AdminSubscriptionDashboard({
</div> </div>
); );
} }
+2 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,7 +9,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useSession } from "@/lib/hooks/use-session"; import { useSession } from "@/lib/hooks/use-session";
@@ -58,7 +58,7 @@ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
} else { } else {
setError(data.error || "Erro ao fazer login"); setError(data.error || "Erro ao fazer login");
} }
} catch (err) { } catch (_err) {
setError("Erro ao conectar com o servidor"); setError("Erro ao conectar com o servidor");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -116,4 +116,3 @@ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
</Dialog> </Dialog>
); );
} }
+101 -37
View File
@@ -1,6 +1,10 @@
"use client"; "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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,22 +12,29 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } 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 { interface RegisterDialogProps {
open: boolean; open: boolean;
@@ -39,7 +50,11 @@ interface ServerSelection {
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) { export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const t = useTranslations("auth.register"); const t = useTranslations("auth.register");
const { refreshSession } = useSession(); const { refreshSession } = useSession();
const { servers, isLoading: serversLoading, error: serversError } = useServers(); const {
servers,
isLoading: serversLoading,
error: serversError,
} = useServers();
// Form fields // Form fields
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -49,7 +64,8 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [selectedCountry, setSelectedCountry] = useState<Country>(DEFAULT_COUNTRY); const [selectedCountry, setSelectedCountry] =
useState<Country>(DEFAULT_COUNTRY);
const [countrySearch, setCountrySearch] = useState(""); const [countrySearch, setCountrySearch] = useState("");
// Servers selection // Servers selection
@@ -61,7 +77,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); 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 // Filtered servers based on search
const filteredServers = useMemo(() => { const filteredServers = useMemo(() => {
@@ -70,7 +88,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
return servers.filter( return servers.filter(
(s) => (s) =>
s.serverName.toLowerCase().includes(searchLower) && s.serverName.toLowerCase().includes(searchLower) &&
!selectedServers.some((sel) => sel.serverName === s.serverName) !selectedServers.some((sel) => sel.serverName === s.serverName),
); );
}, [serverSearch, servers, selectedServers]); }, [serverSearch, servers, selectedServers]);
@@ -145,7 +163,11 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
}; };
const handlePhoneChange = (value: string) => { const handlePhoneChange = (value: string) => {
const formatted = formatInternationalPhone(value, selectedCountry.dialCode, selectedCountry.code); const formatted = formatInternationalPhone(
value,
selectedCountry.dialCode,
selectedCountry.code,
);
setPhone(formatted); setPhone(formatted);
}; };
@@ -157,7 +179,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
(c) => (c) =>
c.name.toLowerCase().includes(searchLower) || c.name.toLowerCase().includes(searchLower) ||
c.dialCode.includes(searchLower) || c.dialCode.includes(searchLower) ||
c.code.toLowerCase().includes(searchLower) c.code.toLowerCase().includes(searchLower),
); );
}, [countrySearch]); }, [countrySearch]);
@@ -233,7 +255,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
password, password,
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
phone: phone ? combinePhoneWithDialCode(selectedCountry.dialCode, phone) : undefined, phone: phone
? combinePhoneWithDialCode(selectedCountry.dialCode, phone)
: undefined,
servers: selectedServers.map((s) => ({ servers: selectedServers.map((s) => ({
serverName: s.serverName, serverName: s.serverName,
characterName: s.characterName.trim(), characterName: s.characterName.trim(),
@@ -246,7 +270,10 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) { if (!contentType || !contentType.includes("application/json")) {
const text = await response.text(); 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"); setError("Erro: resposta inválida do servidor");
setIsLoading(false); setIsLoading(false);
return; return;
@@ -270,7 +297,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
setError(data.error || "Erro ao criar conta"); setError(data.error || "Erro ao criar conta");
} }
} }
} catch (err) { } catch (_err) {
setError("Erro ao conectar com o servidor"); setError("Erro ao conectar com o servidor");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -313,7 +340,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
className={fieldErrors.username ? "border-destructive" : ""} className={fieldErrors.username ? "border-destructive" : ""}
/> />
{fieldErrors.username && ( {fieldErrors.username && (
<p className="text-sm text-destructive">{fieldErrors.username}</p> <p className="text-sm text-destructive">
{fieldErrors.username}
</p>
)} )}
</div> </div>
@@ -351,7 +380,12 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
<div <div
className={`h-full ${getPasswordStrengthColor()} transition-all`} className={`h-full ${getPasswordStrengthColor()} transition-all`}
style={{ style={{
width: passwordStrength === "weak" ? "33%" : passwordStrength === "medium" ? "66%" : "100%", width:
passwordStrength === "weak"
? "33%"
: passwordStrength === "medium"
? "66%"
: "100%",
}} }}
/> />
</div> </div>
@@ -362,7 +396,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</div> </div>
)} )}
{fieldErrors.password && ( {fieldErrors.password && (
<p className="text-sm text-destructive">{fieldErrors.password}</p> <p className="text-sm text-destructive">
{fieldErrors.password}
</p>
)} )}
</div> </div>
@@ -375,10 +411,14 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
className={fieldErrors.confirmPassword ? "border-destructive" : ""} className={
fieldErrors.confirmPassword ? "border-destructive" : ""
}
/> />
{fieldErrors.confirmPassword && ( {fieldErrors.confirmPassword && (
<p className="text-sm text-destructive">{fieldErrors.confirmPassword}</p> <p className="text-sm text-destructive">
{fieldErrors.confirmPassword}
</p>
)} )}
</div> </div>
</div> </div>
@@ -400,7 +440,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
className={fieldErrors.firstName ? "border-destructive" : ""} className={fieldErrors.firstName ? "border-destructive" : ""}
/> />
{fieldErrors.firstName && ( {fieldErrors.firstName && (
<p className="text-sm text-destructive">{fieldErrors.firstName}</p> <p className="text-sm text-destructive">
{fieldErrors.firstName}
</p>
)} )}
</div> </div>
@@ -416,7 +458,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
className={fieldErrors.lastName ? "border-destructive" : ""} className={fieldErrors.lastName ? "border-destructive" : ""}
/> />
{fieldErrors.lastName && ( {fieldErrors.lastName && (
<p className="text-sm text-destructive">{fieldErrors.lastName}</p> <p className="text-sm text-destructive">
{fieldErrors.lastName}
</p>
)} )}
</div> </div>
</div> </div>
@@ -435,12 +479,17 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span>{selectedCountry.flag}</span> <span>{selectedCountry.flag}</span>
<span className="text-sm">{selectedCountry.dialCode}</span> <span className="text-sm">
{selectedCountry.dialCode}
</span>
</span> </span>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> </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"> <div className="p-2">
<Input <Input
type="text" type="text"
@@ -463,7 +512,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const reformatted = formatInternationalPhone( const reformatted = formatInternationalPhone(
unformatted, unformatted,
country.dialCode, country.dialCode,
country.code country.code,
); );
setPhone(reformatted); setPhone(reformatted);
} }
@@ -473,7 +522,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
<span className="flex items-center gap-2 w-full"> <span className="flex items-center gap-2 w-full">
<span>{country.flag}</span> <span>{country.flag}</span>
<span className="flex-1">{country.name}</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> </span>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -504,7 +555,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold">{t("servers")}</h3> <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>
</div> </div>
@@ -587,15 +640,23 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`character-${index}`}>{t("characterName")}</Label> <Label htmlFor={`character-${index}`}>
{t("characterName")}
</Label>
<Input <Input
id={`character-${index}`} id={`character-${index}`}
type="text" type="text"
placeholder={t("characterNamePlaceholder")} placeholder={t("characterNamePlaceholder")}
value={server.characterName} value={server.characterName}
onChange={(e) => updateServerCharacter(index, e.target.value)} onChange={(e) =>
updateServerCharacter(index, e.target.value)
}
disabled={isLoading} disabled={isLoading}
className={fieldErrors[`server_${index}`] ? "border-destructive" : ""} className={
fieldErrors[`server_${index}`]
? "border-destructive"
: ""
}
/> />
{fieldErrors[`server_${index}`] && ( {fieldErrors[`server_${index}`] && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -624,7 +685,11 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</div> </div>
)} )}
<Button type="submit" className="w-full" disabled={isLoading || serversLoading}> <Button
type="submit"
className="w-full"
disabled={isLoading || serversLoading}
>
{isLoading ? t("submitting") : t("submit")} {isLoading ? t("submitting") : t("submit")}
</Button> </Button>
</form> </form>
@@ -632,4 +697,3 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
</Dialog> </Dialog>
); );
} }
+15 -13
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { Coins, DollarSign, MapPin, Package, User } from "lucide-react";
import { Package, MapPin, DollarSign, Coins, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { Chat } from "@/lib/hooks/use-chat"; import type { Chat } from "@/lib/hooks/use-chat";
interface ChatHeaderProps { interface ChatHeaderProps {
@@ -17,20 +17,23 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache; chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
const [imageError, setImageError] = useState(false); 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 (price === null || price === undefined) return "N/A";
if (type === "pix") { if (type === "pix") {
return new Intl.NumberFormat("pt-BR", { return new Intl.NumberFormat("pt-BR", {
style: "currency", style: "currency",
currency: "BRL", currency: "BRL",
}).format(price); }).format(price);
} }
if (type === "tc") { if (type === "tc") {
return `${price.toLocaleString("pt-BR")} TC`; return `${price.toLocaleString("pt-BR")} TC`;
} }
if (type === "gp") { if (type === "gp") {
const gold = price / 1000000; const gold = price / 1000000;
if (gold >= 1) { if (gold >= 1) {
@@ -38,7 +41,7 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
} }
return `${price.toLocaleString("pt-BR")} gp`; return `${price.toLocaleString("pt-BR")} gp`;
} }
return price.toLocaleString("pt-BR"); return price.toLocaleString("pt-BR");
}; };
@@ -63,7 +66,9 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
{/* Detalhes do anúncio */} {/* Detalhes do anúncio */}
<div className="flex-1 min-w-0"> <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-3 text-sm text-muted-foreground mt-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
@@ -102,9 +107,7 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
{/* Informações do outro usuário */} {/* Informações do outro usuário */}
<div className="flex items-center gap-2 pt-2 border-t"> <div className="flex items-center gap-2 pt-2 border-t">
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">Conversando com: </span>
Conversando com:{" "}
</span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{otherUser.character_name || otherUser.username} {otherUser.character_name || otherUser.username}
</span> </span>
@@ -112,4 +115,3 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
</div> </div>
); );
} }
+6 -9
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { ChatHeader } from "./ChatHeader"; import { Skeleton } from "@/components/ui/skeleton";
import { MessageList } from "./MessageList";
import { MessageInput } from "./MessageInput";
import { useChat } from "@/lib/hooks/use-chat"; import { useChat } from "@/lib/hooks/use-chat";
import { useSession } from "@/lib/hooks/use-session"; 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 { interface ChatModalProps {
open: boolean; open: boolean;
@@ -104,9 +104,7 @@ export function ChatModal({
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0"> <DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Chat com vendedor</DialogTitle> <DialogTitle>Chat com vendedor</DialogTitle>
<DialogDescription> <DialogDescription>Conversa sobre o anúncio</DialogDescription>
Conversa sobre o anúncio
</DialogDescription>
</DialogHeader> </DialogHeader>
{isCreatingChat || (isLoading && !chat) ? ( {isCreatingChat || (isLoading && !chat) ? (
@@ -144,4 +142,3 @@ export function ChatModal({
</Dialog> </Dialog>
); );
} }
+5 -8
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/lib/hooks/use-chat"; import type { ChatMessage } from "@/lib/hooks/use-chat";
import { cn } from "@/lib/utils";
interface MessageBubbleProps { interface MessageBubbleProps {
message: ChatMessage; message: ChatMessage;
@@ -31,7 +31,7 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
<div <div
className={cn( className={cn(
"flex w-full mb-2", "flex w-full mb-2",
isOwn ? "justify-end" : "justify-start" isOwn ? "justify-end" : "justify-start",
)} )}
> >
<div <div
@@ -39,7 +39,7 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
"max-w-[70%] rounded-lg px-4 py-2", "max-w-[70%] rounded-lg px-4 py-2",
isOwn isOwn
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-foreground" : "bg-muted text-foreground",
)} )}
> >
<p className="text-sm whitespace-pre-wrap break-words"> <p className="text-sm whitespace-pre-wrap break-words">
@@ -48,16 +48,13 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
<div <div
className={cn( className={cn(
"flex items-center gap-1 mt-1 text-xs", "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> <span>{formatTime(message.created_at)}</span>
{isOwn && message.read_at && ( {isOwn && message.read_at && <span className="opacity-70">✓✓</span>}
<span className="opacity-70">✓✓</span>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
+4 -5
View File
@@ -1,9 +1,9 @@
"use client"; "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 { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Send, Loader2 } from "lucide-react";
import { useChatTyping } from "@/lib/hooks/use-chat"; import { useChatTyping } from "@/lib/hooks/use-chat";
import { useSocket } from "@/lib/hooks/use-socket"; import { useSocket } from "@/lib/hooks/use-socket";
@@ -25,7 +25,7 @@ export function MessageInput({
const { handleTypingStart, handleTypingStop } = useChatTyping( const { handleTypingStart, handleTypingStop } = useChatTyping(
socket, socket,
isConnected, isConnected,
chatId chatId,
); );
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -46,7 +46,7 @@ export function MessageInput({
const handleChange = (value: string) => { const handleChange = (value: string) => {
setContent(value); setContent(value);
if (value.trim().length > 0) { if (value.trim().length > 0) {
handleTypingStart(); handleTypingStart();
} else { } else {
@@ -85,4 +85,3 @@ export function MessageInput({
</div> </div>
); );
} }
+6 -5
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { MessageBubble } from "./MessageBubble";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import type { ChatMessage } from "@/lib/hooks/use-chat"; import type { ChatMessage } from "@/lib/hooks/use-chat";
import { MessageBubble } from "./MessageBubble";
interface MessageListProps { interface MessageListProps {
messages: ChatMessage[]; messages: ChatMessage[];
@@ -40,7 +40,7 @@ export function MessageList({
if (messages.length > 0 && !isLoading) { if (messages.length > 0 && !isLoading) {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" }); messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
} }
}, [isLoading]); }, [isLoading, messages.length]);
// Detectar scroll no topo para carregar mais // Detectar scroll no topo para carregar mais
const handleScroll = () => { const handleScroll = () => {
@@ -50,7 +50,7 @@ export function MessageList({
if (container.scrollTop === 0) { if (container.scrollTop === 0) {
const previousScrollHeight = container.scrollHeight; const previousScrollHeight = container.scrollHeight;
onLoadMore(); onLoadMore();
// Ajustar scroll após carregar mais mensagens // Ajustar scroll após carregar mais mensagens
setTimeout(() => { setTimeout(() => {
const newScrollHeight = container.scrollHeight; 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 className="flex items-center justify-center h-full text-center text-muted-foreground">
<div> <div>
<p className="text-sm">Nenhuma mensagem ainda</p> <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>
</div> </div>
) : ( ) : (
@@ -115,4 +117,3 @@ export function MessageList({
</div> </div>
); );
} }
+26 -27
View File
@@ -1,32 +1,31 @@
"use client"; "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 confetti from "canvas-confetti";
import { import {
Heart,
Coffee,
Pizza,
Loader2,
Copy,
Check, Check,
Lock,
User,
CheckCircle, CheckCircle,
CheckCircle2, CheckCircle2,
DollarSign, Coffee,
Copy,
Heart,
Loader2,
Lock,
Pizza,
User,
} from "lucide-react"; } 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DonationTier, DonationResponse } from "@/types/donation"; import { Input } from "@/components/ui/input";
import { useTranslations } from "next-intl"; import { Textarea } from "@/components/ui/textarea";
import type { DonationResponse, DonationTier } from "@/types/donation";
interface DonationCardProps extends DonationTier {} interface DonationCardProps extends DonationTier {}
@@ -61,7 +60,7 @@ export function DonationCard({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = 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 [showThankYouModal, setShowThankYouModal] = useState(false);
const t = useTranslations("donation"); const t = useTranslations("donation");
@@ -94,7 +93,7 @@ export function DonationCard({
const updateTimer = () => { const updateTimer = () => {
try { try {
const expires = new Date(pixData!.expiresAt!); const expires = new Date(pixData?.expiresAt!);
const now = new Date(); const now = new Date();
const diff = expires.getTime() - now.getTime(); const diff = expires.getTime() - now.getTime();
@@ -118,7 +117,7 @@ export function DonationCard({
try { try {
const response = await fetch( const response = await fetch(
`/api/donations/status?pixId=${pixData.pixId}` `/api/donations/status?pixId=${pixData.pixId}`,
); );
const data = await response.json(); const data = await response.json();
@@ -346,7 +345,7 @@ export function DonationCard({
const isValidCustomAmount = () => { const isValidCustomAmount = () => {
const amount = parseFloat(customAmount); const amount = parseFloat(customAmount);
return ( return (
!isNaN(amount) && !Number.isNaN(amount) &&
amount >= customProduct.minAmount && amount >= customProduct.minAmount &&
amount <= customProduct.maxAmount amount <= customProduct.maxAmount
); );
@@ -363,12 +362,12 @@ export function DonationCard({
// Garante apenas um separador decimal // Garante apenas um separador decimal
const parts = normalized.split("."); const parts = normalized.split(".");
if (parts.length > 2) { if (parts.length > 2) {
return parts[0] + "." + parts.slice(1).join(""); return `${parts[0]}.${parts.slice(1).join("")}`;
} }
// Limita 2 casas decimais // Limita 2 casas decimais
if (parts[1]?.length > 2) { if (parts[1]?.length > 2) {
return parts[0] + "." + parts[1].substring(0, 2); return `${parts[0]}.${parts[1].substring(0, 2)}`;
} }
return normalized; return normalized;
@@ -396,12 +395,12 @@ export function DonationCard({
setError("Digite um valor"); setError("Digite um valor");
return; return;
} }
const amount = parseFloat(customAmount); const _amount = parseFloat(customAmount);
if (!isValidCustomAmount()) { if (!isValidCustomAmount()) {
setError( setError(
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed( `Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
2 2,
)} e R$ ${customProduct.maxAmount.toFixed(2)}` )} e R$ ${customProduct.maxAmount.toFixed(2)}`,
); );
return; return;
} }
@@ -463,10 +462,10 @@ export function DonationCard({
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{parseFloat(customAmount) < customProduct.minAmount {parseFloat(customAmount) < customProduct.minAmount
? `${t( ? `${t(
"form.minAmount" "form.minAmount",
)} ${customProduct.minAmount.toFixed(2)}` )} ${customProduct.minAmount.toFixed(2)}`
: `${t( : `${t(
"form.maxAmount" "form.maxAmount",
)} ${customProduct.maxAmount.toFixed(2)}`} )} ${customProduct.maxAmount.toFixed(2)}`}
</p> </p>
)} )}
@@ -485,7 +484,7 @@ export function DonationCard({
</> </>
) : customAmount ? ( ) : customAmount ? (
`${t("form.donate")} ${parseFloat( `${t("form.donate")} ${parseFloat(
customAmount || "0" customAmount || "0",
).toFixed(2)}` ).toFixed(2)}`
) : ( ) : (
t("form.chooseAmount") t("form.chooseAmount")
+4 -4
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Coffee, Heart } from "lucide-react";
import { Heart, Coffee } from "lucide-react";
import { BlurFade } from "@/components/ui/blur-fade";
import { useTranslations } from "next-intl";
import Link from "next/link"; 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() { export function DonationFooterCard() {
const t = useTranslations("donation"); const t = useTranslations("donation");
+173 -147
View File
@@ -1,45 +1,39 @@
"use client"; "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 { import {
TrendingUp, ArrowDown,
TrendingDown, ArrowUp,
Sword, Boxes,
Shield,
Weight,
Heart,
Star,
Clock, Clock,
Coins, Coins,
ArrowUp,
ArrowDown,
Minus,
Zap,
Flame,
Snowflake,
Zap as Energy,
Skull,
Cross, Cross,
Badge as BadgeIcon, Zap as Energy,
Boxes, Flame,
Gem, FlaskConical,
FlaskConical, Gem,
Wrench, Heart,
ScrollText, Leaf,
Leaf, Minus,
ScrollText,
Shield,
Skull,
Snowflake,
Star,
Sword,
TrendingDown,
TrendingUp,
Weight,
Wrench,
Zap,
} from "lucide-react"; } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { Badge } from "@/components/ui/badge";
getRarityColor, import { Button } from "@/components/ui/button";
getRarityBgColor, import { MagicCard } from "@/components/ui/magic-card";
getRarityGlow, import { PriceDisplay } from "@/components/ui/price-display";
} from "@/lib/currency-utils";
import { formatNumber } from "@/lib/utils/formatters"; import { formatNumber } from "@/lib/utils/formatters";
import type { TibiaItem } from "@/types/item";
interface ItemCardProps { interface ItemCardProps {
item: TibiaItem; item: TibiaItem;
@@ -50,7 +44,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item"); const t = useTranslations("home.item");
const locale = useLocale(); const locale = useLocale();
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [_isHovered, setIsHovered] = useState(false);
const formatVocation = (v: string | undefined): string | undefined => { const formatVocation = (v: string | undefined): string | undefined => {
if (!v) return undefined; if (!v) return undefined;
let out = String(v); let out = String(v);
@@ -64,14 +58,22 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
return out; 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 scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false); const [_canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false); const [_canScrollRight, setCanScrollRight] = useState(false);
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const autoScrollRef = useRef<number | null>(null); 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 [isDragging, setIsDragging] = useState(false);
const updateButtons = () => { const updateButtons = () => {
@@ -114,9 +116,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
el.removeEventListener("mouseleave", onLeave); el.removeEventListener("mouseleave", onLeave);
ro.disconnect(); ro.disconnect();
}; };
}, []); }, [updateButtons]);
const scrollBy = (dx: number) => { const _scrollBy = (dx: number) => {
const el = scrollerRef.current; const el = scrollerRef.current;
if (!el) return; if (!el) return;
const base = contentRef.current?.offsetWidth || 0; const base = contentRef.current?.offsetWidth || 0;
@@ -182,7 +184,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
if (!drag.current.active) return; if (!drag.current.active) return;
drag.current.active = false; drag.current.active = false;
setIsDragging(false); setIsDragging(false);
try { el.releasePointerCapture(e.pointerId); } catch {} try {
el.releasePointerCapture(e.pointerId);
} catch {}
}; };
el.addEventListener("pointerdown", onPointerDown); el.addEventListener("pointerdown", onPointerDown);
@@ -204,7 +208,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
ref={scrollerRef} ref={scrollerRef}
className={`overflow-x-auto no-scrollbar whitespace-nowrap pr-6 pl-6 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} className={`overflow-x-auto no-scrollbar whitespace-nowrap pr-6 pl-6 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
style={{ scrollBehavior: "smooth" }} style={{ scrollBehavior: "smooth" }}
> >
<div ref={contentRef} className="inline-flex items-center gap-2"> <div ref={contentRef} className="inline-flex items-center gap-2">
{children} {children}
{children} {children}
@@ -213,10 +217,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div> </div>
); );
}; };
const [showAllDroppers, setShowAllDroppers] = useState(false);
const rarityColors = { const [_showAllDroppers, _setShowAllDroppers] = useState(false);
const _rarityColors = {
common: "bg-gray-500", common: "bg-gray-500",
uncommon: "bg-green-500", uncommon: "bg-green-500",
rare: "bg-blue-500", rare: "bg-blue-500",
@@ -251,14 +255,20 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
(item as any).price.sell !== 0; (item as any).price.sell !== 0;
const priceDifference = hasPrice const priceDifference = hasPrice
? (((item as any).price.buy - (item as any).price.sell) / ? (((item as any).price.buy - (item as any).price.sell) /
(item as any).price.sell) * 100 (item as any).price.sell) *
100
: 0; : 0;
const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20% const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20%
// Determinar tendência de preço // Determinar tendência de preço
const getPriceTrend = () => { const getPriceTrend = () => {
const ph = (item as any).priceHistory; 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"; return "stable";
} }
if (ph.highest > ph.average * 1.1) return "up"; if (ph.highest > ph.average * 1.1) return "up";
@@ -273,8 +283,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
trend === "up" trend === "up"
? "text-green-500" ? "text-green-500"
: trend === "down" : trend === "down"
? "text-red-500" ? "text-red-500"
: "text-gray-500"; : "text-gray-500";
return ( return (
<div <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"> <div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
{typeof CategoryIcon === "string" ? ( {typeof CategoryIcon === "string" ? null : (
null
) : (
<CategoryIcon className="w-10 h-10" /> <CategoryIcon className="w-10 h-10" />
)} )}
</div> </div>
@@ -352,7 +360,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{(() => { {(() => {
const catPt = (item as any).categoryPt as string | undefined; const catPt = (item as any).categoryPt as string | undefined;
const catEn = (item as any).category 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 const display = catRaw
? catRaw.charAt(0).toUpperCase() + catRaw.slice(1) ? catRaw.charAt(0).toUpperCase() + catRaw.slice(1)
: ""; : "";
@@ -375,9 +384,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{item.stats.attack && ( {item.stats.attack && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Sword className="h-4 w-4 text-primary" /> <Sword className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">{item.stats.attack} ATK</span>
{item.stats.attack} ATK
</span>
</div> </div>
)} )}
{item.stats.defense && ( {item.stats.defense && (
@@ -391,21 +398,23 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
{item.stats.armor && ( {item.stats.armor && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Shield className="h-4 w-4 text-primary" /> <Shield className="h-4 w-4 text-primary" />
<span className="font-medium"> <span className="font-medium">{item.stats.armor} ARM</span>
{item.stats.armor} ARM
</span>
</div> </div>
)} )}
{(item as any).volume && ( {(item as any).volume && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Boxes className="h-4 w-4 text-primary" /> <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> </div>
)} )}
{(item as any).bonus_de_skill && ( {(item as any).bonus_de_skill && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Zap className="h-4 w-4 text-primary" /> <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>
)} )}
</div> </div>
@@ -457,84 +466,91 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</div> </div>
)} )}
{(item as any).protecao && (() => { {(item as any).protecao &&
const raw = String((item as any).protecao || ""); (() => {
const tokens = raw const raw = String((item as any).protecao || "");
.split(",") const tokens = raw
.map((s) => s.trim()) .split(",")
.filter(Boolean) .map((s) => s.trim())
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim())); .filter(Boolean)
if (tokens.length === 0) return null; .filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
return ( if (tokens.length === 0) return null;
<ScrollRow ariaLabel="elemental protections"> return (
{tokens.map((raw: string, idx: number) => { <ScrollRow ariaLabel="elemental protections">
const token = raw.trim(); {tokens.map((raw: string, idx: number) => {
const match = token.match(/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/); const token = raw.trim();
const type = (match?.[1] || token).trim().toLowerCase(); const match = token.match(
const value = (match?.[2] || "").trim(); /^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/,
let Icon: any = Shield; );
let chipBg = "bg-amber-500/10"; const type = (match?.[1] || token).trim().toLowerCase();
let chipText = "text-amber-700"; const value = (match?.[2] || "").trim();
switch (type) { let Icon: any = Shield;
case "fire": let chipBg = "bg-amber-500/10";
case "fogo": let chipText = "text-amber-700";
Icon = Flame; switch (type) {
chipBg = "bg-red-500/10"; case "fire":
chipText = "text-red-600"; case "fogo":
break; Icon = Flame;
case "ice": chipBg = "bg-red-500/10";
case "gelo": chipText = "text-red-600";
Icon = Snowflake; break;
chipBg = "bg-blue-500/10"; case "ice":
chipText = "text-blue-600"; case "gelo":
break; Icon = Snowflake;
case "energy": chipBg = "bg-blue-500/10";
case "energia": chipText = "text-blue-600";
Icon = Energy; break;
chipBg = "bg-yellow-500/10"; case "energy":
chipText = "text-yellow-600"; case "energia":
break; Icon = Energy;
case "earth": chipBg = "bg-yellow-500/10";
case "terra": chipText = "text-yellow-600";
Icon = Leaf; break;
chipBg = "bg-green-500/10"; case "earth":
chipText = "text-green-600"; case "terra":
break; Icon = Leaf;
case "death": chipBg = "bg-green-500/10";
case "morte": chipText = "text-green-600";
Icon = Skull; break;
chipBg = "bg-zinc-500/10"; case "death":
chipText = "text-zinc-600"; case "morte":
break; Icon = Skull;
case "holy": chipBg = "bg-zinc-500/10";
case "sagrado": chipText = "text-zinc-600";
Icon = Cross; break;
chipBg = "bg-amber-500/10"; case "holy":
chipText = "text-amber-700"; case "sagrado":
break; Icon = Cross;
case "physical": chipBg = "bg-amber-500/10";
case "fisico": chipText = "text-amber-700";
Icon = Shield; break;
chipBg = "bg-slate-500/10"; case "physical":
chipText = "text-slate-600"; case "fisico":
break; Icon = Shield;
default: chipBg = "bg-slate-500/10";
Icon = Shield; chipText = "text-slate-600";
chipBg = "bg-amber-500/10"; break;
chipText = "text-amber-700"; default:
} Icon = Shield;
const prettyType = type.charAt(0).toUpperCase() + type.slice(1); chipBg = "bg-amber-500/10";
const label = `${prettyType.replace("fisico", "Físico")}${value ? " " + value : ""}`; chipText = "text-amber-700";
return ( }
<div key={`${type}-${idx}`} className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}> const prettyType =
<Icon className="h-3 w-3" /> type.charAt(0).toUpperCase() + type.slice(1);
<span className="whitespace-nowrap">{label}</span> const label = `${prettyType.replace("fisico", "Físico")}${value ? ` ${value}` : ""}`;
</div> return (
); <div
})} key={`${type}-${idx}`}
</ScrollRow> 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 && ( {item.stats.weight && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <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> </div>
)} )}
{(item as any).dropa_de && ( {(item as any).dropa_de &&
(() => { (() => {
const raw = String((item as any).dropa_de || ""); const raw = String((item as any).dropa_de || "");
const normalized = raw.trim(); const normalized = raw.trim();
@@ -588,8 +604,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
</ScrollRow> </ScrollRow>
</div> </div>
); );
})() })()}
)}
{mode === "market" && item.price && ( {mode === "market" && item.price && (
<> <>
@@ -649,7 +664,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{t("priceHistory")} {t("priceHistory")}
</span> </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" /> <TrendIcon className="h-3 w-3" />
<span className="capitalize">{trend}</span> <span className="capitalize">{trend}</span>
</div> </div>
@@ -661,21 +678,27 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<TrendingUp className="h-3 w-3" /> <TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span> <span className="font-medium">{t("highest")}</span>
</div> </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>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600"> <div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" /> <TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span> <span className="font-medium">{t("lowest")}</span>
</div> </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>
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground"> <div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" /> <Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span> <span className="font-medium">{t("average")}</span>
</div> </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> </div>
</div> </div>
@@ -690,7 +713,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
> >
{t("viewDetails")} {t("viewDetails")}
</Button> </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")} {t("buyNow")}
</Button> </Button>
</div> </div>
+3 -9
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -10,8 +11,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
const languages = [ const languages = [
{ code: "pt", name: "Português", flag: "🇧🇷" }, { code: "pt", name: "Português", flag: "🇧🇷" },
@@ -37,7 +36,7 @@ export function LanguageSwitcher() {
router.push(`/${newLocale}${pathWithoutLocale}`); 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 // Renderizar placeholder durante SSR para evitar mismatch
if (!mounted) { if (!mounted) {
@@ -72,8 +71,3 @@ export function LanguageSwitcher() {
</DropdownMenu> </DropdownMenu>
); );
} }
+44 -54
View File
@@ -1,20 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Button } from "@/components/ui/button";
Search,
Filter,
SortAsc,
SortDesc,
X,
Star,
TrendingUp,
Clock,
} from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -23,6 +13,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface MarketplaceFiltersProps { interface MarketplaceFiltersProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
@@ -48,7 +39,7 @@ export function MarketplaceFilters({
showRarity = true, showRarity = true,
categoryOptionsOverride, categoryOptionsOverride,
}: MarketplaceFiltersProps) { }: MarketplaceFiltersProps) {
const t = useTranslations("home"); const _t = useTranslations("home");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<FilterState>({ const [activeFilters, setActiveFilters] = useState<FilterState>({
rarity: [], rarity: [],
@@ -142,7 +133,9 @@ export function MarketplaceFilters({
}; };
const activeFiltersCount = const activeFiltersCount =
activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0); activeFilters.rarity.length +
activeFilters.category.length +
(activeFilters.vocation ? 1 : 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -159,39 +152,39 @@ export function MarketplaceFilters({
{/* Filtros e ordenação */} {/* Filtros e ordenação */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{/* Filtro por raridade (opcional) */} {/* Filtro por raridade (opcional) */}
{showRarity && ( {showRarity && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
Raridade Raridade
{activeFilters.rarity.length > 0 && ( {activeFilters.rarity.length > 0 && (
<Badge variant="secondary" className="ml-1"> <Badge variant="secondary" className="ml-1">
{activeFilters.rarity.length} {activeFilters.rarity.length}
</Badge> </Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuLabel>Filtrar por Raridade</DropdownMenuLabel>
<DropdownMenuSeparator />
{rarityOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => toggleRarityFilter(option.value)}
className="flex items-center gap-2"
>
<div className={`w-3 h-3 rounded-full ${option.color}`} />
<span>{option.label}</span>
{activeFilters.rarity.includes(option.value) && (
<X className="h-3 w-3 ml-auto" />
)} )}
</DropdownMenuItem> </Button>
))} </DropdownMenuTrigger>
</DropdownMenuContent> <DropdownMenuContent className="w-48">
</DropdownMenu> <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 */} {/* Filtro por categoria */}
<DropdownMenu> <DropdownMenu>
@@ -247,7 +240,9 @@ export function MarketplaceFilters({
const newFilters = { const newFilters = {
...activeFilters, ...activeFilters,
vocation: vocation:
activeFilters.vocation === option.value ? null : option.value, activeFilters.vocation === option.value
? null
: option.value,
}; };
setActiveFilters(newFilters); setActiveFilters(newFilters);
onFilter(newFilters); onFilter(newFilters);
@@ -322,7 +317,7 @@ export function MarketplaceFilters({
})} })}
{activeFilters.category.map((category) => { {activeFilters.category.map((category) => {
const option = categoryOptions.find( const option = categoryOptions.find(
(opt) => opt.value === category (opt) => opt.value === category,
); );
return ( return (
<Badge <Badge
@@ -362,8 +357,3 @@ export function MarketplaceFilters({
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
+10 -4
View File
@@ -1,10 +1,17 @@
"use client"; "use client";
import {
Clock,
Eye,
Heart,
MessageSquare,
MousePointerClick,
TrendingUp,
} from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/hooks/use-session"; import { useSession } from "@/lib/hooks/use-session";
import { TrendingUp, Eye, MousePointerClick, Heart, MessageSquare, Clock } from "lucide-react";
interface AdAnalytics { interface AdAnalytics {
ad_id: number; ad_id: number;
@@ -37,13 +44,13 @@ export function AnalyticsDashboard() {
if (user?.userId) { if (user?.userId) {
loadUserListings(); loadUserListings();
} }
}, [user?.userId]); }, [user?.userId, loadUserListings]);
useEffect(() => { useEffect(() => {
if (selectedAd) { if (selectedAd) {
loadAnalytics(selectedAd); loadAnalytics(selectedAd);
} }
}, [selectedAd]); }, [selectedAd, loadAnalytics]);
const loadUserListings = async () => { const loadUserListings = async () => {
setLoading(true); setLoading(true);
@@ -266,4 +273,3 @@ export function AnalyticsDashboard() {
</div> </div>
); );
} }
+8 -11
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AuctionCountdownProps { interface AuctionCountdownProps {
@@ -30,7 +30,7 @@ export function AuctionCountdown({
const calculateTimeRemaining = () => { const calculateTimeRemaining = () => {
const endTime = new Date(endsAt).getTime(); const endTime = new Date(endsAt).getTime();
const now = new Date().getTime(); const now = Date.now();
const total = endTime - now; const total = endTime - now;
if (total <= 0) { if (total <= 0) {
@@ -61,19 +61,19 @@ export function AuctionCountdown({
// Determinar cor baseada no tempo restante // Determinar cor baseada no tempo restante
const getColorClass = () => { const getColorClass = () => {
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes; const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
if (timeRemaining.total <= 0) { if (timeRemaining.total <= 0) {
return "text-red-600 dark:text-red-400"; return "text-red-600 dark:text-red-400";
} }
if (totalMinutes < 5) { if (totalMinutes < 5) {
return "text-red-500 dark:text-red-400 font-semibold animate-pulse"; return "text-red-500 dark:text-red-400 font-semibold animate-pulse";
} }
if (totalMinutes < 60) { if (totalMinutes < 60) {
return "text-yellow-600 dark:text-yellow-400"; return "text-yellow-600 dark:text-yellow-400";
} }
return "text-green-600 dark:text-green-400"; return "text-green-600 dark:text-green-400";
}; };
@@ -84,7 +84,7 @@ export function AuctionCountdown({
} }
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes; const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
if (totalMinutes < 5) { if (totalMinutes < 5) {
return "Termina em breve"; return "Termina em breve";
} }
@@ -102,13 +102,10 @@ export function AuctionCountdown({
return ( return (
<div className={cn("flex items-center gap-1.5", className)}> <div className={cn("flex items-center gap-1.5", className)}>
{showIcon && ( {showIcon && <Clock className={cn("h-3.5 w-3.5", getColorClass())} />}
<Clock className={cn("h-3.5 w-3.5", getColorClass())} />
)}
<span className={cn("text-xs font-medium", getColorClass())}> <span className={cn("text-xs font-medium", getColorClass())}>
{getTimeText()} {getTimeText()}
</span> </span>
</div> </div>
); );
} }
+78 -36
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { Loader2, TrendingUp } from "lucide-react";
import { Separator } from "@/components/ui/separator"; import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,7 +11,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@@ -20,7 +20,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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"; import { useSession } from "@/lib/hooks/use-session";
interface Listing { interface Listing {
@@ -45,20 +45,27 @@ interface BidModalProps {
onSuccess?: () => void; onSuccess?: () => void;
} }
export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalProps) { export function BidModal({
open,
onOpenChange,
listing,
onSuccess,
}: BidModalProps) {
const { user } = useSession(); const { user } = useSession();
const [bidAmount, setBidAmount] = useState<string>(""); const [bidAmount, setBidAmount] = useState<string>("");
const [paymentMethod, setPaymentMethod] = useState<string>(""); const [paymentMethod, setPaymentMethod] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [bidHistory, setBidHistory] = useState<Array<{ const [bidHistory, setBidHistory] = useState<
bid_id: number; Array<{
bidder_id: number; bid_id: number;
bidder_name: string; bidder_id: number;
bid_amount: number; bidder_name: string;
bid_type: string | null; bid_amount: number;
created_at: Date; bid_type: string | null;
}>>([]); created_at: Date;
}>
>([]);
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
if (!listing) return null; if (!listing) return null;
@@ -67,41 +74,50 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
const currentBid = listing.current_bid_amount || 0; const currentBid = listing.current_bid_amount || 0;
const minIncrement = listing.min_bid_increment || 10; const minIncrement = listing.min_bid_increment || 10;
const minBid = currentBid + minIncrement; 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 // Determinar métodos de pagamento disponíveis para lance
const availableMethods: string[] = []; const availableMethods: string[] = [];
if (listing.price_pix || listing.initial_price_pix) availableMethods.push("pix"); if (listing.price_pix || listing.initial_price_pix)
if (listing.price_tibia_coins || listing.initial_price_tibia_coins) availableMethods.push("tc"); availableMethods.push("pix");
if (listing.price_gold_coins || listing.initial_price_gold_coins) availableMethods.push("gp"); 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 // Selecionar primeiro método disponível por padrão
useEffect(() => { useEffect(() => {
if (availableMethods.length > 0 && !paymentMethod) { if (availableMethods.length > 0 && !paymentMethod) {
setPaymentMethod(availableMethods[0]); setPaymentMethod(availableMethods[0]);
} }
}, [availableMethods.length]); }, [paymentMethod]);
// Atualizar valor do lance quando mudar // Atualizar valor do lance quando mudar
useEffect(() => { useEffect(() => {
if (bidAmount === "" && minBid > 0) { if (bidAmount === "" && minBid > 0) {
setBidAmount(minBid.toFixed(2)); setBidAmount(minBid.toFixed(2));
} }
}, [minBid]); }, [minBid, bidAmount]);
// Buscar histórico de lances quando abrir modal // Buscar histórico de lances quando abrir modal
useEffect(() => { useEffect(() => {
if (open && listing) { if (open && listing) {
fetchBidHistory(); fetchBidHistory();
} }
}, [open, listing?.listing_id]); }, [open, listing?.listing_id, fetchBidHistory, listing]);
const fetchBidHistory = async () => { const fetchBidHistory = async () => {
if (!listing) return; if (!listing) return;
setLoadingHistory(true); setLoadingHistory(true);
try { 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) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -117,14 +133,16 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
const handleBid = async () => { const handleBid = async () => {
const amount = parseFloat(bidAmount); const amount = parseFloat(bidAmount);
if (isNaN(amount) || amount <= 0) { if (Number.isNaN(amount) || amount <= 0) {
setError("Valor do lance inválido"); setError("Valor do lance inválido");
return; return;
} }
if (amount < minBid) { 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; return;
} }
@@ -198,11 +216,17 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label className="text-xs text-muted-foreground">Preço Inicial</Label> <Label className="text-xs text-muted-foreground">
<p className="text-lg font-semibold">{formatPrice(initialPrice)}</p> Preço Inicial
</Label>
<p className="text-lg font-semibold">
{formatPrice(initialPrice)}
</p>
</div> </div>
<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"> <p className="text-lg font-semibold text-primary">
{currentBid > 0 ? formatPrice(currentBid) : "Sem lances"} {currentBid > 0 ? formatPrice(currentBid) : "Sem lances"}
</p> </p>
@@ -211,18 +235,28 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label className="text-xs text-muted-foreground">Incremento Mínimo</Label> <Label className="text-xs text-muted-foreground">
<p className="text-sm font-medium">{formatPrice(minIncrement)}</p> Incremento Mínimo
</Label>
<p className="text-sm font-medium">
{formatPrice(minIncrement)}
</p>
</div> </div>
<div> <div>
<Label className="text-xs text-muted-foreground">Lance Mínimo Necessário</Label> <Label className="text-xs text-muted-foreground">
<p className="text-sm font-semibold text-green-600">{formatPrice(minBid)}</p> Lance Mínimo Necessário
</Label>
<p className="text-sm font-semibold text-green-600">
{formatPrice(minBid)}
</p>
</div> </div>
</div> </div>
{listing.auction_ends_at && ( {listing.auction_ends_at && (
<div> <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"> <p className="text-sm">
{new Date(listing.auction_ends_at).toLocaleString("pt-BR")} {new Date(listing.auction_ends_at).toLocaleString("pt-BR")}
</p> </p>
@@ -235,7 +269,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
{/* Histórico de lances */} {/* Histórico de lances */}
{bidHistory.length > 0 && ( {bidHistory.length > 0 && (
<div className="space-y-2"> <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"> <div className="bg-muted rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{bidHistory.map((bid, index) => { {bidHistory.map((bid, index) => {
const isCurrentUser = user?.userId === bid.bidder_id; const isCurrentUser = user?.userId === bid.bidder_id;
@@ -244,7 +280,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
<div <div
key={bid.bid_id} key={bid.bid_id}
className={`p-2 rounded ${ 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" : ""}`} } ${isHighest ? "border-l-2 border-l-primary" : ""}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -343,7 +381,12 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
</Button> </Button>
<Button <Button
onClick={handleBid} onClick={handleBid}
disabled={loading || !bidAmount || parseFloat(bidAmount) < minBid || !paymentMethod} disabled={
loading ||
!bidAmount ||
parseFloat(bidAmount) < minBid ||
!paymentMethod
}
> >
{loading ? ( {loading ? (
<> <>
@@ -363,4 +406,3 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
</Dialog> </Dialog>
); );
} }
+29 -17
View File
@@ -1,6 +1,9 @@
"use client"; "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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,12 +11,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -21,9 +20,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/hooks/use-session"; import { useSession } from "@/lib/hooks/use-session";
import { ItemSearch } from "./ItemSearch"; import { ItemSearch } from "./ItemSearch";
import { ArrowRight, Package } from "lucide-react";
interface Item { interface Item {
id: number; id: number;
@@ -47,7 +47,11 @@ interface Server {
isActive: boolean; isActive: boolean;
} }
export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalProps) { export function CreateAdModal({
open,
onOpenChange,
onSuccess,
}: CreateAdModalProps) {
const { user } = useSession(); const { user } = useSession();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -97,7 +101,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
if (!hasPrice && (!acceptTrade || !tradeItem)) { if (!hasPrice && (!acceptTrade || !tradeItem)) {
setError( 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); setLoading(false);
return; return;
@@ -130,9 +134,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
description: formData.description || null, description: formData.description || null,
trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null, trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null,
trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null, trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null,
trade_item_quantity: acceptTrade && tradeItem trade_item_quantity:
? parseInt(formData.trade_item_quantity, 10) acceptTrade && tradeItem
: null, ? 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", auction_ends_at: "Término do leilão",
}; };
const validationMessages = data.details.map((detail: { field?: string; message?: string }) => { const validationMessages = data.details.map(
const fieldLabel = detail.field ? fieldLabels[detail.field] || detail.field : "Campo"; (detail: { field?: string; message?: string }) => {
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`; const fieldLabel = detail.field
}); ? fieldLabels[detail.field] || detail.field
: "Campo";
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
},
);
setError(validationMessages.join(" ")); setError(validationMessages.join(" "));
} else { } else {
@@ -199,7 +208,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
if (open) { if (open) {
loadServers(); loadServers();
} }
}, [open]); }, [open, loadServers]);
const loadServers = async () => { const loadServers = async () => {
setLoadingServers(true); setLoadingServers(true);
@@ -295,7 +304,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
</SelectItem> </SelectItem>
) : ( ) : (
servers.map((server) => ( servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverName}> <SelectItem
key={server.serverId}
value={server.serverName}
>
{server.serverName} {server.serverName}
{server.location && ` (${server.location})`} {server.location && ` (${server.location})`}
</SelectItem> </SelectItem>
+80 -39
View File
@@ -1,6 +1,20 @@
"use client"; "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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,24 +22,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
ShoppingCart,
Clock,
CheckCircle2,
XCircle,
MessageSquare,
User,
Loader2,
ShoppingBag,
} from "lucide-react";
import { useSession } from "@/lib/hooks/use-session"; import { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface PurchaseIntention { interface PurchaseIntention {
intention_id: number; intention_id: number;
@@ -72,21 +72,30 @@ function getStatusBadge(status: string) {
switch (status) { switch (status) {
case "pending": case "pending":
return ( 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" /> <Clock className="h-3 w-3 mr-1" />
Pendente Pendente
</Badge> </Badge>
); );
case "accepted": case "accepted":
return ( 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" /> <CheckCircle2 className="h-3 w-3 mr-1" />
Aceita Aceita
</Badge> </Badge>
); );
case "rejected": case "rejected":
return ( 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" /> <XCircle className="h-3 w-3 mr-1" />
Rejeitada Rejeitada
</Badge> </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 ( if (
intention.custom_offer_amount === null || intention.custom_offer_amount === null ||
intention.custom_offer_amount === undefined || intention.custom_offer_amount === undefined ||
@@ -142,7 +153,10 @@ export function IntentionDetailModal({
const [sellerResponse, setSellerResponse] = useState(""); const [sellerResponse, setSellerResponse] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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 [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false); const [markingSold, setMarkingSold] = useState(false);
@@ -161,7 +175,11 @@ export function IntentionDetailModal({
}, [open, listingId]); }, [open, listingId]);
const isOwner = user && listing && user.userId === listing.seller_id; 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 canAcceptOrReject = intention.status === "pending";
const customOffer = formatCustomOffer(intention); const customOffer = formatCustomOffer(intention);
@@ -169,7 +187,7 @@ export function IntentionDetailModal({
if (open) { if (open) {
setSellerResponse(intention.seller_response ?? ""); setSellerResponse(intention.seller_response ?? "");
} }
}, [open, intention.intention_id, intention.seller_response]); }, [open, intention.seller_response]);
const handleAccept = async () => { const handleAccept = async () => {
setLoading(true); setLoading(true);
@@ -185,7 +203,7 @@ export function IntentionDetailModal({
status: "accepted", status: "accepted",
seller_response: sellerResponse || undefined, seller_response: sellerResponse || undefined,
}), }),
} },
); );
const data = await res.json(); const data = await res.json();
@@ -217,7 +235,7 @@ export function IntentionDetailModal({
status: "rejected", status: "rejected",
seller_response: sellerResponse || undefined, seller_response: sellerResponse || undefined,
}), }),
} },
); );
const data = await res.json(); const data = await res.json();
@@ -229,7 +247,9 @@ export function IntentionDetailModal({
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
onOpenChange(false); onOpenChange(false);
} catch (err) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -241,7 +261,7 @@ export function IntentionDetailModal({
const res = await fetch(`/api/listings/${listingId}/mark-sold`, { const res = await fetch(`/api/listings/${listingId}/mark-sold`, {
method: "POST", method: "POST",
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido"); throw new Error(data.error || "Erro ao marcar como vendido");
@@ -250,13 +270,15 @@ export function IntentionDetailModal({
// Invalidar cache para atualizar a lista // Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] }); queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] }); queryClient.invalidateQueries({ queryKey: ["my-listings"] });
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
setShowMarkSoldDialog(false); setShowMarkSoldDialog(false);
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error("Erro ao marcar como vendido:", 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 { } finally {
setMarkingSold(false); setMarkingSold(false);
} }
@@ -271,7 +293,8 @@ export function IntentionDetailModal({
{getStatusBadge(intention.status)} {getStatusBadge(intention.status)}
</DialogTitle> </DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -304,7 +327,9 @@ export function IntentionDetailModal({
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <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"> <div className="flex items-center gap-2 mt-1">
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> <ShoppingCart className="h-4 w-4 text-muted-foreground" />
<span className="font-medium"> <span className="font-medium">
@@ -313,7 +338,9 @@ export function IntentionDetailModal({
</div> </div>
</div> </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"> <div className="flex items-center gap-2 mt-1">
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm"> <span className="text-sm">
@@ -328,9 +355,13 @@ export function IntentionDetailModal({
<Label className="text-xs text-primary uppercase tracking-wide"> <Label className="text-xs text-primary uppercase tracking-wide">
Oferta do Comprador Oferta do Comprador
</Label> </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 && ( {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> </div>
)} )}
@@ -398,7 +429,11 @@ export function IntentionDetailModal({
Marcar como Vendido Marcar como Vendido
</Button> </Button>
)} )}
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading || markingSold}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading || markingSold}
>
Fechar Fechar
</Button> </Button>
{canAcceptOrReject && ( {canAcceptOrReject && (
@@ -420,7 +455,10 @@ export function IntentionDetailModal({
</> </>
)} )}
</Button> </Button>
<Button onClick={handleAccept} disabled={loading || markingSold}> <Button
onClick={handleAccept}
disabled={loading || markingSold}
>
{loading ? ( {loading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -445,14 +483,18 @@ export function IntentionDetailModal({
<DialogHeader> <DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle> <DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground"> <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 será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</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> <li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul> </ul>
<p className="pt-2 font-medium text-foreground"> <p className="pt-2 font-medium text-foreground">
@@ -480,4 +522,3 @@ export function IntentionDetailModal({
</Dialog> </Dialog>
); );
} }
+37 -28
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input"; import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input";
import { Search, X, Loader2 } from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface Item { interface Item {
@@ -38,7 +38,9 @@ export function ItemSearch({
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showDropdown, setShowDropdown] = 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 searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined); const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -58,7 +60,7 @@ export function ItemSearch({
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
try { try {
const res = await fetch( const res = await fetch(
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10` `/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`,
); );
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -76,7 +78,10 @@ export function ItemSearch({
// Fechar dropdown ao clicar fora // Fechar dropdown ao clicar fora
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { 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); setShowDropdown(false);
} }
}; };
@@ -159,7 +164,9 @@ export function ItemSearch({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.name}</p> <p className="text-sm font-medium truncate">{item.name}</p>
{item.category && ( {item.category && (
<p className="text-xs text-muted-foreground">{item.category}</p> <p className="text-xs text-muted-foreground">
{item.category}
</p>
)} )}
</div> </div>
{item.rarity && ( {item.rarity && (
@@ -200,24 +207,27 @@ export function ItemSearch({
{previewItem.rarity && ( {previewItem.rarity && (
<Badge variant="secondary">{previewItem.rarity}</Badge> <Badge variant="secondary">{previewItem.rarity}</Badge>
)} )}
{previewItem.level !== undefined && previewItem.level !== null && ( {previewItem.level !== undefined &&
<span className="flex items-center gap-1"> previewItem.level !== null && (
<span className="font-medium">Level:</span> <span className="flex items-center gap-1">
{previewItem.level} <span className="font-medium">Level:</span>
</span> {previewItem.level}
)} </span>
{previewItem.weight !== undefined && previewItem.weight !== null && ( )}
<span className="flex items-center gap-1"> {previewItem.weight !== undefined &&
<span className="font-medium">Peso:</span> previewItem.weight !== null && (
{previewItem.weight} oz <span className="flex items-center gap-1">
</span> <span className="font-medium">Peso:</span>
)} {previewItem.weight} oz
{previewItem.npc_price !== undefined && previewItem.npc_price !== null && ( </span>
<span className="flex items-center gap-1"> )}
<span className="font-medium">Preço NPC:</span> {previewItem.npc_price !== undefined &&
{previewItem.npc_price.toLocaleString()} gp previewItem.npc_price !== null && (
</span> <span className="flex items-center gap-1">
)} <span className="font-medium">Preço NPC:</span>
{previewItem.npc_price.toLocaleString()} gp
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -227,4 +237,3 @@ export function ItemSearch({
</div> </div>
); );
} }
+181 -71
View File
@@ -1,34 +1,32 @@
"use client"; "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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"; } 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 { useSession } from "@/lib/hooks/use-session";
import { useQueryClient } from "@tanstack/react-query";
interface Listing { interface Listing {
listing_id: number; listing_id: number;
@@ -76,10 +74,11 @@ interface ListingDetailsModalProps {
function formatTimeAgo(date: Date | string): string { function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date; 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"; if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60); 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); const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`; if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
@@ -92,14 +91,40 @@ function formatTimeAgo(date: Date | string): string {
function getStatusBadge(status: string) { function getStatusBadge(status: string) {
const statusConfig = { const statusConfig = {
active: { label: "Ativo", variant: "default" as const, icon: CheckCircle2, className: "bg-green-500 hover:bg-green-600" }, active: {
sold: { label: "Vendido", variant: "secondary" as const, icon: CheckCircle2, className: "bg-blue-500 hover:bg-blue-600 text-white" }, label: "Ativo",
cancelled: { label: "Cancelado", variant: "destructive" as const, icon: X, className: "bg-red-500 hover:bg-red-600 text-white" }, variant: "default" as const,
in_escrow: { label: "Em Escrow", variant: "default" as const, icon: AlertCircle, className: "bg-yellow-500 hover:bg-yellow-600 text-white" }, icon: CheckCircle2,
expired: { label: "Expirado", variant: "secondary" as const, icon: Clock, className: "bg-gray-500 hover:bg-gray-600 text-white" }, 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; const Icon = config.icon;
return ( return (
@@ -123,7 +148,7 @@ export function ListingDetailsModal({
const [itemImageUrl, setItemImageUrl] = useState<string>(""); const [itemImageUrl, setItemImageUrl] = useState<string>("");
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false); const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
const [markingSold, setMarkingSold] = useState(false); const [markingSold, setMarkingSold] = useState(false);
// Verificar se o usuário é o dono do anúncio // Verificar se o usuário é o dono do anúncio
const isOwner = user && user.userId === listing.seller_id; const isOwner = user && user.userId === listing.seller_id;
@@ -134,13 +159,19 @@ export function ListingDetailsModal({
} else if (listing.image_local_path) { } else if (listing.image_local_path) {
const normalizedPath = listing.image_local_path.replace(/\\/g, "/"); const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
if (normalizedPath.startsWith("data/")) { if (normalizedPath.startsWith("data/")) {
setItemImageUrl(`/api/images?path=${encodeURIComponent(normalizedPath)}`); setItemImageUrl(
`/api/images?path=${encodeURIComponent(normalizedPath)}`,
);
} else { } else {
const category = listing.item_category || "other"; 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) { } 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) { if (listing.item_name) {
params.set("name", 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 }> = []; const prices: Array<{ type: string; label: string; value: string }> = [];
if (listing.price_pix) { if (listing.price_pix) {
const price = typeof listing.price_pix === "string" ? parseFloat(listing.price_pix) : listing.price_pix; const price =
if (!isNaN(price)) { typeof listing.price_pix === "string"
prices.push({ type: "pix", label: "PIX", value: `R$ ${price.toFixed(2)}` }); ? 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) { 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 =
if (!isNaN(price)) { typeof listing.price_tibia_coins === "string"
prices.push({ type: "tibia_coins", label: "Tibia Coins", value: `${price.toLocaleString()} TC` }); ? 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) { 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 =
if (!isNaN(price)) { typeof listing.price_gold_coins === "string"
prices.push({ type: "gold_coins", label: "Gold Coins", value: `${price.toLocaleString()} GP` }); ? 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({ prices.push({
type: "trade", type: "trade",
label: "Troca", label: "Troca",
@@ -220,7 +276,10 @@ export function ListingDetailsModal({
const canPause = isOwner && listing.listing_status === "active"; const canPause = isOwner && listing.listing_status === "active";
const canActivate = isOwner && listing.listing_status === "cancelled"; const canActivate = isOwner && listing.listing_status === "cancelled";
const canCancel = isOwner && listing.listing_status === "active"; 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 () => { const handleMarkAsSold = async () => {
setMarkingSold(true); setMarkingSold(true);
@@ -228,7 +287,7 @@ export function ListingDetailsModal({
const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, { const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, {
method: "POST", method: "POST",
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json();
throw new Error(data.error || "Erro ao marcar como vendido"); throw new Error(data.error || "Erro ao marcar como vendido");
@@ -237,12 +296,14 @@ export function ListingDetailsModal({
// Invalidar cache para atualizar a lista // Invalidar cache para atualizar a lista
queryClient.invalidateQueries({ queryKey: ["listings"] }); queryClient.invalidateQueries({ queryKey: ["listings"] });
queryClient.invalidateQueries({ queryKey: ["my-listings"] }); queryClient.invalidateQueries({ queryKey: ["my-listings"] });
setShowMarkSoldDialog(false); setShowMarkSoldDialog(false);
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error("Erro ao marcar como vendido:", 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 { } finally {
setMarkingSold(false); setMarkingSold(false);
} }
@@ -256,7 +317,9 @@ export function ListingDetailsModal({
Detalhes do Anúncio Detalhes do Anúncio
{getStatusBadge(listing.listing_status)} {getStatusBadge(listing.listing_status)}
</DialogTitle> </DialogTitle>
<DialogDescription>Informações completas do seu anúncio</DialogDescription> <DialogDescription>
Informações completas do seu anúncio
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-6">
@@ -294,22 +357,30 @@ export function ListingDetailsModal({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" /> <Eye className="h-4 w-4 text-muted-foreground" />
<div> <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 className="font-semibold">{listing.views_count}</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" /> <Heart className="h-4 w-4 text-muted-foreground" />
<div> <div>
<div className="text-sm text-muted-foreground">Favoritos</div> <div className="text-sm text-muted-foreground">
<div className="font-semibold">{listing.favorites_count || 0}</div> Favoritos
</div>
<div className="font-semibold">
{listing.favorites_count || 0}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
<div> <div>
<div className="text-sm text-muted-foreground">Criado</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> </div>
</div> </div>
@@ -349,7 +420,9 @@ export function ListingDetailsModal({
: "bg-purple-500/10 border-purple-500/20" : "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 className="font-semibold">{price.value}</div>
</div> </div>
))} ))}
@@ -370,26 +443,44 @@ export function ListingDetailsModal({
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Lance Atual:</span> <span className="text-muted-foreground">Lance Atual:</span>
<span className="font-semibold text-lg text-primary"> <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> </span>
</div> </div>
{listing.min_bid_increment && ( {listing.min_bid_increment && (
<div className="flex justify-between"> <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"> <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> </span>
</div> </div>
)} )}
{listing.auction_ends_at && ( {listing.auction_ends_at && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Termina em:</span> <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> </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> </div>
)} )}
@@ -398,7 +489,9 @@ export function ListingDetailsModal({
{listing.description && ( {listing.description && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-semibold">Descrição</h4> <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> </div>
)} )}
@@ -410,13 +503,19 @@ export function ListingDetailsModal({
</div> </div>
{listing.item_id_unified && ( {listing.item_id_unified && (
<div> <div>
<span className="text-muted-foreground">ID do Item (Unificado):</span> <span className="text-muted-foreground">
<span className="ml-2 font-mono">{listing.item_id_unified}</span> ID do Item (Unificado):
</span>
<span className="ml-2 font-mono">
{listing.item_id_unified}
</span>
</div> </div>
)} )}
<div> <div>
<span className="text-muted-foreground">Criado em:</span> <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>
</div> </div>
@@ -424,9 +523,9 @@ export function ListingDetailsModal({
{(canPause || canActivate || canCancel || canMarkAsSold) && ( {(canPause || canActivate || canCancel || canMarkAsSold) && (
<div className="flex gap-2 pt-4 border-t"> <div className="flex gap-2 pt-4 border-t">
{canMarkAsSold && ( {canMarkAsSold && (
<Button <Button
variant="default" variant="default"
onClick={() => setShowMarkSoldDialog(true)} onClick={() => setShowMarkSoldDialog(true)}
className="flex-1 bg-green-600 hover:bg-green-700" className="flex-1 bg-green-600 hover:bg-green-700"
> >
<ShoppingBag className="h-4 w-4 mr-2" /> <ShoppingBag className="h-4 w-4 mr-2" />
@@ -440,13 +539,21 @@ export function ListingDetailsModal({
</Button> </Button>
)} )}
{canActivate && onActivate && ( {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" /> <CheckCircle2 className="h-4 w-4 mr-2" />
Reativar Anúncio Reativar Anúncio
</Button> </Button>
)} )}
{canCancel && ( {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" /> <Trash2 className="h-4 w-4 mr-2" />
Cancelar Anúncio Cancelar Anúncio
</Button> </Button>
@@ -462,14 +569,18 @@ export function ListingDetailsModal({
<DialogHeader> <DialogHeader>
<DialogTitle>Marcar anúncio como vendido?</DialogTitle> <DialogTitle>Marcar anúncio como vendido?</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground"> <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 será marcado como "Vendido"</li>
<li>O anúncio continuará visível na lista por mais 1 hora</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> <li>Nenhuma interação (comprar, dar lance) será permitida</li>
</ul> </ul>
<p className="pt-2 font-medium text-foreground"> <p className="pt-2 font-medium text-foreground">
@@ -497,4 +608,3 @@ export function ListingDetailsModal({
</Dialog> </Dialog>
); );
} }
+47 -17
View File
@@ -1,11 +1,17 @@
"use client"; "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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; 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"; import { IntentionDetailModal } from "./IntentionDetailModal";
interface PurchaseIntention { interface PurchaseIntention {
@@ -32,10 +38,11 @@ interface MyAdsIntentionListProps {
} }
function formatTimeAgo(date: Date): string { 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"; if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60); 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); const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`; if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
@@ -46,21 +53,30 @@ function getStatusBadge(status: string) {
switch (status) { switch (status) {
case "pending": case "pending":
return ( 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" /> <Clock className="h-3 w-3 mr-1" />
Pendente Pendente
</Badge> </Badge>
); );
case "accepted": case "accepted":
return ( 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" /> <CheckCircle2 className="h-3 w-3 mr-1" />
Aceita Aceita
</Badge> </Badge>
); );
case "rejected": case "rejected":
return ( 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" /> <XCircle className="h-3 w-3 mr-1" />
Rejeitada Rejeitada
</Badge> </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 [intentions, setIntentions] = useState<PurchaseIntention[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedIntention, setSelectedIntention] = useState<PurchaseIntention | null>(null); const [selectedIntention, setSelectedIntention] =
const [filter, setFilter] = useState<"all" | "pending" | "accepted" | "rejected">("all"); useState<PurchaseIntention | null>(null);
const [filter, setFilter] = useState<
"all" | "pending" | "accepted" | "rejected"
>("all");
useEffect(() => { useEffect(() => {
loadIntentions(); loadIntentions();
}, [listingId, filter]); }, [loadIntentions]);
const loadIntentions = async () => { const loadIntentions = async () => {
setLoading(true); setLoading(true);
@@ -124,7 +146,9 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
if (filter !== "all") { if (filter !== "all") {
params.set("status", filter); 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) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -216,19 +240,26 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2"> <div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<ShoppingCart className="h-3 w-3" /> <ShoppingCart className="h-3 w-3" />
<span>{getPaymentMethodLabel(intention.payment_method)}</span> <span>
{getPaymentMethodLabel(intention.payment_method)}
</span>
<span>•</span> <span>•</span>
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<span>{formatTimeAgo(new Date(intention.created_at))}</span> <span>{formatTimeAgo(new Date(intention.created_at))}</span>
</div> </div>
{formatCustomOffer(intention) && ( {formatCustomOffer(intention) && (
<div className="flex items-center gap-2 mb-1"> <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)} Oferta: {formatCustomOffer(intention)}
</Badge> </Badge>
{intention.custom_offer_created_at && ( {intention.custom_offer_created_at && (
<span className="text-[10px] text-muted-foreground"> <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> </span>
)} )}
</div> </div>
@@ -261,4 +292,3 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
</div> </div>
); );
} }
+73 -67
View File
@@ -1,39 +1,43 @@
"use client"; "use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; 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 { import {
Pause, ChevronDown,
Play,
Trash2,
Edit,
Eye, Eye,
Heart, Heart,
TrendingUp, Pause,
ChevronDown, Play,
ShoppingCart, ShoppingCart,
Trash2,
TrendingUp,
} from "lucide-react"; } 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 { useSession } from "@/lib/hooks/use-session";
import { useMyListings, type Listing } from "@/lib/hooks/use-listings";
import { AdCard } from "./AdCard";
import { MyAdsIntentionList } from "./MyAdsIntentionList"; import { MyAdsIntentionList } from "./MyAdsIntentionList";
export function MyAdsPanel() { export function MyAdsPanel() {
const { user } = useSession(); const { user } = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<string>("active"); 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 // Determinar status baseado na aba ativa
const status = const status =
activeTab === "active" ? "active" : activeTab === "active"
activeTab === "paused" ? "cancelled" : // Backend usa 'cancelled' para pausados ? "active"
activeTab === "sold" ? "sold" : : activeTab === "paused"
undefined; // "all" busca todos ? "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 // Usar React Query para buscar meus anúncios com atualização automática
// O queryKey muda quando status muda, então recarrega automaticamente // O queryKey muda quando status muda, então recarrega automaticamente
@@ -145,9 +149,7 @@ export function MyAdsPanel() {
</div> </div>
) : listings.length === 0 ? ( ) : listings.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-muted-foreground"> <p className="text-muted-foreground">Nenhum anúncio encontrado</p>
Nenhum anúncio encontrado
</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -180,21 +182,25 @@ export function MyAdsPanel() {
<span> <span>
{(() => { {(() => {
if (listing.price_pix) { if (listing.price_pix) {
const price = typeof listing.price_pix === 'string' const price =
? parseFloat(listing.price_pix) typeof listing.price_pix === "string"
: listing.price_pix; ? parseFloat(listing.price_pix)
: listing.price_pix;
return `R$ ${price.toFixed(2)}`; return `R$ ${price.toFixed(2)}`;
} }
if (listing.price_tibia_coins) { if (listing.price_tibia_coins) {
const price = typeof listing.price_tibia_coins === 'string' const price =
? parseInt(listing.price_tibia_coins, 10) typeof listing.price_tibia_coins ===
: listing.price_tibia_coins; "string"
? parseInt(listing.price_tibia_coins, 10)
: listing.price_tibia_coins;
return `${price.toLocaleString()} TC`; return `${price.toLocaleString()} TC`;
} }
if (listing.price_gold_coins) { if (listing.price_gold_coins) {
const price = typeof listing.price_gold_coins === 'string' const price =
? parseInt(listing.price_gold_coins, 10) typeof listing.price_gold_coins === "string"
: listing.price_gold_coins; ? parseInt(listing.price_gold_coins, 10)
: listing.price_gold_coins;
return `${price.toLocaleString()} GP`; return `${price.toLocaleString()} GP`;
} }
return "Preço não informado"; return "Preço não informado";
@@ -236,42 +242,43 @@ export function MyAdsPanel() {
</div> </div>
{/* Seção de Intenções de Compra */} {/* Seção de Intenções de Compra */}
{listing.listing_status === "active" && !listing.is_auction && ( {listing.listing_status === "active" &&
<div className="border-t pt-4"> !listing.is_auction && (
<Button <div className="border-t pt-4">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => { size="sm"
const newExpanded = new Set(expandedIntentions); onClick={() => {
if (isExpanded) { const newExpanded = new Set(expandedIntentions);
newExpanded.delete(listing.listing_id); if (isExpanded) {
} else { newExpanded.delete(listing.listing_id);
newExpanded.add(listing.listing_id); } else {
} newExpanded.add(listing.listing_id);
setExpandedIntentions(newExpanded); }
}} setExpandedIntentions(newExpanded);
className="w-full justify-between" }}
> className="w-full justify-between"
<div className="flex items-center gap-2"> >
<ShoppingCart className="h-4 w-4" /> <div className="flex items-center gap-2">
<span>Intenções de Compra</span> <ShoppingCart className="h-4 w-4" />
</div> <span>Intenções de Compra</span>
<ChevronDown </div>
className={`h-4 w-4 transition-transform ${ <ChevronDown
isExpanded ? "transform rotate-180" : "" 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}
/> />
</div> </Button>
)} {isExpanded && (
</div> <div className="mt-4">
)} <MyAdsIntentionList
listingId={listing.listing_id}
sellerId={listing.seller_id}
/>
</div>
)}
</div>
)}
</div> </div>
); );
})} })}
@@ -282,4 +289,3 @@ export function MyAdsPanel() {
</div> </div>
); );
} }
@@ -1,31 +1,28 @@
"use client"; "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 { import {
Clock,
CheckCircle2,
XCircle,
MessageSquare,
ShoppingCart,
MapPin,
Package,
User,
ExternalLink,
AlertCircle, AlertCircle,
DollarSign, CheckCircle2,
Clock,
Coins, Coins,
DollarSign,
ExternalLink,
MapPin,
MessageSquare,
Package,
RefreshCw, RefreshCw,
ShoppingCart,
User,
XCircle,
} from "lucide-react"; } 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 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 { 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -33,14 +30,22 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 { function formatTimeAgo(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date; 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"; if (seconds < 60) return "há alguns segundos";
const minutes = Math.floor(seconds / 60); 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); const hours = Math.floor(minutes / 60);
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`; if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
@@ -55,35 +60,50 @@ function getStatusBadge(status: PurchaseIntentionStatus) {
switch (status) { switch (status) {
case "pending": case "pending":
return ( 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" /> <Clock className="h-3 w-3 mr-1" />
Pendente Pendente
</Badge> </Badge>
); );
case "accepted": case "accepted":
return ( 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" /> <CheckCircle2 className="h-3 w-3 mr-1" />
Aceita Aceita
</Badge> </Badge>
); );
case "rejected": case "rejected":
return ( 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" /> <XCircle className="h-3 w-3 mr-1" />
Rejeitada Rejeitada
</Badge> </Badge>
); );
case "expired": case "expired":
return ( 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" /> <AlertCircle className="h-3 w-3 mr-1" />
Expirada Expirada
</Badge> </Badge>
); );
case "completed": case "completed":
return ( 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" /> <CheckCircle2 className="h-3 w-3 mr-1" />
Concluída Concluída
</Badge> </Badge>
@@ -110,18 +130,18 @@ function getPaymentMethodLabel(method: string): string {
function formatPrice(price: number | null, method: string): string { function formatPrice(price: number | null, method: string): string {
if (price === null) return "N/A"; if (price === null) return "N/A";
if (method === "pix") { if (method === "pix") {
return new Intl.NumberFormat("pt-BR", { return new Intl.NumberFormat("pt-BR", {
style: "currency", style: "currency",
currency: "BRL", currency: "BRL",
}).format(price); }).format(price);
} }
if (method === "tibia_coins") { if (method === "tibia_coins") {
return `${price.toLocaleString("pt-BR")} TC`; return `${price.toLocaleString("pt-BR")} TC`;
} }
if (method === "gold_coins") { if (method === "gold_coins") {
const gold = price / 1000000; const gold = price / 1000000;
if (gold >= 1) { 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")} gp`;
} }
return price.toLocaleString("pt-BR"); 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) { if (amount === null || amount === undefined || !currency) {
return null; return null;
} }
@@ -171,7 +194,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <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)} {getStatusBadge(intention.status)}
</div> </div>
<p className="text-sm text-muted-foreground"> <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}`)}`; return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`;
} }
if (listing.item_id_unified) { 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) { if (listing.item_name) {
params.set("name", 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}` ? `${listing.listing_id}_${intention.buyer_id}_${listing.seller_id}`
: null); : null);
const price = listing.price_pix && intention.payment_method === "pix" const price =
? listing.price_pix listing.price_pix && intention.payment_method === "pix"
: listing.price_tibia_coins && intention.payment_method === "tibia_coins" ? listing.price_pix
? listing.price_tibia_coins : listing.price_tibia_coins && intention.payment_method === "tibia_coins"
: listing.price_gold_coins && intention.payment_method === "gold_coins" ? listing.price_tibia_coins
? listing.price_gold_coins : listing.price_gold_coins && intention.payment_method === "gold_coins"
: null; ? listing.price_gold_coins
: null;
const customOfferLabel = formatCustomOffer( const customOfferLabel = formatCustomOffer(
typeof intention.custom_offer_amount === "number" ? intention.custom_offer_amount : null, typeof intention.custom_offer_amount === "number"
intention.custom_offer_currency ?? null ? intention.custom_offer_amount
: null,
intention.custom_offer_currency ?? null,
); );
return ( return (
<> <>
<Card className="hover:shadow-md transition-shadow"> <Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0"> <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"> <div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{itemImageUrl ? ( {itemImageUrl ? (
<Image <Image
@@ -254,160 +284,172 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3> <h3 className="font-semibold text-lg truncate">
{getStatusBadge(intention.status)} {listing.item_name}
</div> </h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2"> {getStatusBadge(intention.status)}
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{listing.server_name}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
<Package className="h-3 w-3" /> <div className="flex items-center gap-1">
<span>Qtd: {listing.quantity}</span> <MapPin className="h-3 w-3" />
</div> <span>{listing.server_name}</span>
<div className="flex items-center gap-1"> </div>
<ShoppingCart className="h-3 w-3" /> <div className="flex items-center gap-1">
<span>{getPaymentMethodLabel(intention.payment_method)}</span> <Package className="h-3 w-3" />
{price !== null && ( <span>Qtd: {listing.quantity}</span>
<span className="font-medium text-foreground ml-1"> </div>
{formatPrice(price, intention.payment_method)} <div className="flex items-center gap-1">
<ShoppingCart className="h-3 w-3" />
<span>
{getPaymentMethodLabel(intention.payment_method)}
</span> </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> </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>
</div> </div>
</div> </CardHeader>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Informações do vendedor */} {/* Informações do vendedor */}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Vendedor:</span> <span className="text-muted-foreground">Vendedor:</span>
<span className="font-medium"> <span className="font-medium">
{listing.seller_character_name || listing.seller_username || "Desconhecido"} {listing.seller_character_name ||
</span> listing.seller_username ||
</div> "Desconhecido"}
</span>
</div>
{/* Mensagem do comprador */} {/* Mensagem do comprador */}
{intention.buyer_message && ( {intention.buyer_message && (
<div className="bg-muted/50 rounded-md p-3"> <div className="bg-muted/50 rounded-md p-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">Sua mensagem:</p> <p className="text-xs text-muted-foreground mb-1">
<p className="text-sm">{intention.buyer_message}</p> Sua mensagem:
</p>
<p className="text-sm">{intention.buyer_message}</p>
</div>
</div> </div>
</div> </div>
</div> )}
)}
{/* Resposta do vendedor */} {/* Resposta do vendedor */}
{intention.seller_response && ( {intention.seller_response && (
<div <div
className={cn( className={cn(
"rounded-md p-3 border", "rounded-md p-3 border",
intention.status === "accepted" intention.status === "accepted"
? "bg-green-500/10 border-green-500/20" ? "bg-green-500/10 border-green-500/20"
: intention.status === "rejected" : intention.status === "rejected"
? "bg-red-500/10 border-red-500/20" ? "bg-red-500/10 border-red-500/20"
: "bg-blue-500/10 border-blue-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> </div>
)}
{/* Timestamps */} {/* Actions */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t"> <div className="flex items-center gap-2 pt-2 flex-wrap">
<div className="flex items-center gap-1"> {intention.status === "pending" &&
<Clock className="h-3 w-3" /> intention.payment_method_change_count < 1 && (
<span>Criada {formatTimeAgo(intention.created_at)}</span> <ChangePaymentMethodButton
</div> intentionId={intention.intention_id}
{intention.updated_at !== intention.created_at && ( currentPaymentMethod={intention.payment_method}
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span> availableMethods={[
)} ...(listing.price_pix ? ["pix"] : []),
</div> ...(listing.price_tibia_coins ? ["tibia_coins"] : []),
...(listing.price_gold_coins ? ["gold_coins"] : []),
{/* Actions */} ...(listing.trade_item_id ? ["trade"] : []),
<div className="flex items-center gap-2 pt-2 flex-wrap"> ]}
{intention.status === "pending" && canChange={true}
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" && (
<Button <Button
variant="outline"
size="sm" size="sm"
onClick={() => setShowListingModal(true)} onClick={() => setShowListingModal(true)}
className="flex-1" className="flex-1"
> >
<ShoppingCart className="h-4 w-4 mr-2" /> <ExternalLink className="h-4 w-4 mr-2" />
Comprar Agora Ver Anúncio
</Button> </Button>
)} <Button
</div> variant="outline"
</CardContent> size="sm"
</Card> 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}> <Dialog open={showListingModal} onOpenChange={setShowListingModal}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
@@ -434,30 +476,48 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-lg font-semibold">{listing.item_name}</h3> <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>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" /> <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>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Package className="h-4 w-4" /> <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>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
<span>Vendedor:{" "} <span>
Vendedor:{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{listing.seller_character_name || listing.seller_username || "Desconhecido"} {listing.seller_character_name ||
listing.seller_username ||
"Desconhecido"}
</span> </span>
</span> </span>
</div> </div>
</div> </div>
<div className="space-y-2"> <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"> <div className="flex flex-wrap gap-2">
{listing.price_pix && ( {listing.price_pix && (
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
@@ -477,18 +537,23 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
{formatPrice(listing.price_gold_coins, "gold_coins")} {formatPrice(listing.price_gold_coins, "gold_coins")}
</Badge> </Badge>
)} )}
{listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity && ( {listing.trade_item_id &&
<Badge variant="secondary" className="gap-1"> listing.trade_item_name &&
<RefreshCw className="h-3 w-3" /> listing.trade_item_quantity && (
{listing.trade_item_quantity}x {listing.trade_item_name} <Badge variant="secondary" className="gap-1">
</Badge> <RefreshCw className="h-3 w-3" />
)} {listing.trade_item_quantity}x {listing.trade_item_name}
</Badge>
)}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" asChild> <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 Abrir no marketplace
</Link> </Link>
</Button> </Button>
@@ -521,17 +586,25 @@ interface MyPurchaseIntentionsPanelProps {
locale?: string; locale?: string;
} }
export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) { export function MyPurchaseIntentionsPanel({
locale = "pt",
}: MyPurchaseIntentionsPanelProps) {
const { user } = useSession(); const { user } = useSession();
const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all"); const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all");
const { intentions, isLoading } = useMyPurchaseIntentions(); const { intentions, isLoading } = useMyPurchaseIntentions();
// Contar por status // Contar por status
const pendingCount = intentions.filter((i) => i.status === "pending").length; const pendingCount = intentions.filter((i) => i.status === "pending").length;
const acceptedCount = intentions.filter((i) => i.status === "accepted").length; const acceptedCount = intentions.filter(
const rejectedCount = intentions.filter((i) => i.status === "rejected").length; (i) => i.status === "accepted",
).length;
const rejectedCount = intentions.filter(
(i) => i.status === "rejected",
).length;
const expiredCount = intentions.filter((i) => i.status === "expired").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 = const filteredIntentions =
filter === "all" filter === "all"
? intentions ? intentions
@@ -540,7 +613,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
if (!user) { if (!user) {
return ( return (
<div className="text-center py-12"> <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> </div>
); );
} }
@@ -614,7 +689,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
) : filteredIntentions.length === 0 ? ( ) : filteredIntentions.length === 0 ? (
<div className="text-center py-12 border rounded-lg"> <div className="text-center py-12 border rounded-lg">
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" /> <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"> <p className="text-sm text-muted-foreground mb-4">
{filter === "all" {filter === "all"
? "Quando você manifestar interesse em comprar um item, ele aparecerá aqui." ? "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"> <div className="space-y-4">
{filteredIntentions.map((intention) => ( {filteredIntentions.map((intention) => (
<IntentionCard key={intention.intention_id} intention={intention} locale={locale} /> <IntentionCard
key={intention.intention_id}
intention={intention}
locale={locale}
/>
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more