style: clean up code formatting and remove trailing whitespace
refactor: reorganize imports and fix type imports
This commit is contained in:
1 parent
d3704602fd
commit
47c04de6fe
232 files changed
+7140
-4798
No files matched your search
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,4 +13,3 @@ export async function GET() {
|
|||||||
message: "Socket.io está ativo",
|
message: "Socket.io está ativo",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+485
-278
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
Reference in new issue
Block a user