From f4bc93bc8acab0fce5875e264e7cb3387d4476dd Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sun, 9 Nov 2025 15:37:53 -0300 Subject: [PATCH] changes shopping --- app/[locale]/admin/items/new/page.tsx | 56 + app/[locale]/admin/page.tsx | 1 + app/[locale]/layout.tsx | 74 +- app/api/admin/items/analyze/route.ts | 22 + app/layout.tsx | 36 + components/admin/AdminPanelTabs.tsx | 4 +- .../admin/items/AdminItemCreatePage.tsx | 122 ++ .../admin/items/AdminItemCreateView.tsx | 77 + components/admin/items/AdminItemForm.tsx | 1233 +++++++++++++++++ components/admin/items/AdminItemManager.tsx | 24 +- components/admin/items/ItemAiAssistant.tsx | 242 ++++ components/admin/items/ItemCreationModal.tsx | 977 +------------ components/admin/items/ItemImageCapture.tsx | 224 +++ lib/ai/generate-item-structure.ts | 22 +- lib/ai/item-prompt.ts | 145 ++ lib/image/resize.ts | 51 + pnpm-workspace.yaml | 5 + 17 files changed, 2290 insertions(+), 1025 deletions(-) create mode 100644 app/[locale]/admin/items/new/page.tsx create mode 100644 app/layout.tsx create mode 100644 components/admin/items/AdminItemCreatePage.tsx create mode 100644 components/admin/items/AdminItemCreateView.tsx create mode 100644 components/admin/items/AdminItemForm.tsx create mode 100644 components/admin/items/ItemAiAssistant.tsx create mode 100644 components/admin/items/ItemImageCapture.tsx create mode 100644 lib/ai/item-prompt.ts create mode 100644 lib/image/resize.ts diff --git a/app/[locale]/admin/items/new/page.tsx b/app/[locale]/admin/items/new/page.tsx new file mode 100644 index 0000000..06f68b7 --- /dev/null +++ b/app/[locale]/admin/items/new/page.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import Header from "@/components/Header"; +import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePage"; +import { getSession } from "@/lib/redis/session"; +import { getUserById } from "@/lib/prisma/queries/users"; +import { searchAdminItems } from "@/lib/prisma/queries/items"; + +export async function generateMetadata(): Promise { + return { + title: "Novo item | Painel Administrativo", + description: "Cadastre um novo item unificado no catálogo administrativo.", + }; +} + +export default async function AdminNewItemPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (!sessionId) { + redirect(`/${locale}`); + } + + const session = await getSession(sessionId); + if (!session) { + redirect(`/${locale}`); + } + + const adminUser = await getUserById(session.userId); + if (!adminUser || adminUser.user_id !== 1) { + redirect(`/${locale}`); + } + + const initialItems = await searchAdminItems({ page: 1, pageSize: 1 }); + + return ( +
+
+
+ +
+
+ ); +} + diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx index 14013c4..b27f80a 100644 --- a/app/[locale]/admin/page.tsx +++ b/app/[locale]/admin/page.tsx @@ -85,6 +85,7 @@ export default async function AdminPage({ adminUser={adminUser} initialTiers={normalizedTiers} itemsData={initialItems} + locale={locale} initialTab={initialTab} /> diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 220c68d..3154006 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,21 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono, Nunito_Sans } from "next/font/google"; import { NextIntlClientProvider } from "next-intl"; -import { getMessages } from "next-intl/server"; +import { getMessages, setRequestLocale } from "next-intl/server"; +import Script from "next/script"; import { ThemeProvider } from "@/components/theme-provider"; import { QueryProvider } from "@/lib/providers/query-provider"; -import "../globals.css"; - -const nunitoSans = Nunito_Sans({ - variable: "--font-nunito-sans", - subsets: ["latin"], - weight: ["200", "300", "400", "500", "600", "700", "800", "900"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); export async function generateMetadata({ params, @@ -98,6 +86,13 @@ export async function generateMetadata({ images: ["/og-image.png"], creator: "@tibiatrade", }, + icons: { + icon: [ + { url: "/favicon.ico", type: "image/x-icon" }, + { url: "/icon.svg", type: "image/svg+xml" }, + ], + apple: "/apple-touch-icon.png", + }, }; } @@ -109,40 +104,33 @@ export default async function LocaleLayout({ params: Promise<{ locale: string }>; }) { const { locale } = await params; + setRequestLocale(locale); const messages = await getMessages(); const baseUrl = "https://tibiatrade.evandro.dev.br"; return ( - - - - - - + +
{children}
+ + + ); } diff --git a/app/api/admin/items/analyze/route.ts b/app/api/admin/items/analyze/route.ts index 3f40a5e..c232e66 100644 --- a/app/api/admin/items/analyze/route.ts +++ b/app/api/admin/items/analyze/route.ts @@ -17,6 +17,15 @@ export async function POST(request: NextRequest) { scope === "server" && typeof body?.server === "string" ? body.server.trim() : null; + const categories: string[] = Array.isArray(body?.categories) + ? body.categories.filter((item: unknown) => typeof item === "string") + : []; + const servers: string[] = Array.isArray(body?.servers) + ? body.servers.filter((item: unknown) => typeof item === "string") + : []; + const images: string[] = Array.isArray(body?.images) + ? body.images.filter((item: unknown) => typeof item === "string") + : []; if (!name || !description) { return NextResponse.json( @@ -33,6 +42,19 @@ export async function POST(request: NextRequest) { description, scope, server, + categories, + servers, + images: images + .slice(0, 2) + .map((dataUrl) => { + const [meta, base64Part] = dataUrl.split(","); + const mimeMatch = meta?.match(/data:(.*);base64/); + return { + mimeType: mimeMatch?.[1] ?? "image/jpeg", + base64Data: base64Part ?? meta, + }; + }) + .filter((image) => image.base64Data), }); return NextResponse.json( diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8a0a6c1 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata, Viewport } from "next"; +import { Nunito_Sans, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const nunitoSans = Nunito_Sans({ + variable: "--font-nunito-sans", + subsets: ["latin"], + weight: ["200", "300", "400", "500", "600", "700", "800", "900"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Tibia Trade", + description: "Marketplace seguro para negociação de itens e serviços de Tibia.", +}; + +export const viewport: Viewport = { + themeColor: "#09090b", + width: "device-width", + initialScale: 1, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + diff --git a/components/admin/AdminPanelTabs.tsx b/components/admin/AdminPanelTabs.tsx index 23ba9e3..881660c 100644 --- a/components/admin/AdminPanelTabs.tsx +++ b/components/admin/AdminPanelTabs.tsx @@ -17,6 +17,7 @@ interface AdminPanelTabsProps { } | null; initialTiers: any[]; itemsData: AdminItemsResponse; + locale: string; initialTab?: "overview" | "subscriptions" | "items"; } @@ -24,6 +25,7 @@ export function AdminPanelTabs({ adminUser, initialTiers, itemsData, + locale, initialTab = "overview", }: AdminPanelTabsProps) { const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">( @@ -66,7 +68,7 @@ export function AdminPanelTabs({ - + ); diff --git a/components/admin/items/AdminItemCreatePage.tsx b/components/admin/items/AdminItemCreatePage.tsx new file mode 100644 index 0000000..2b0f76a --- /dev/null +++ b/components/admin/items/AdminItemCreatePage.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm"; +import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant"; + +interface AdminItemCreatePageProps { + locale: string; + categories: string[]; + serverOptions: string[]; +} + +export function AdminItemCreatePage({ locale, categories, serverOptions }: AdminItemCreatePageProps) { + const router = useRouter(); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [prefillData, setPrefillData] = useState(null); + + const goBackToList = () => { + router.push(`/${locale}/admin?tab=items`); + }; + + const handleSubmit = async (payload: ItemFormPayload) => { + setSubmitting(true); + setError(null); + + try { + const body: Record = { + name: payload.name, + category: payload.category, + rarity: payload.rarity, + imageUrl: payload.imageUrl, + imageLocalPath: payload.imageLocalPath, + level: payload.level, + weight: payload.weight, + slots: payload.slots, + skill: payload.skill, + description: payload.description, + servers: payload.servers, + tags: payload.tags, + metadata: payload.metadata, + fields: payload.fields, + }; + + const response = await fetch("/api/admin/items", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({ error: "Erro ao criar item" })); + throw new Error(data?.error || data?.message || "Falha ao criar item"); + } + + goBackToList(); + } catch (err) { + console.error(err); + setError(err instanceof Error ? err.message : "Erro ao criar item. Tente novamente."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

Novo item

+

+ Utilize a seção de IA abaixo para gerar automaticamente campos sugeridos a partir de prints e descrições. + Ajuste o que for necessário antes de publicar. +

+
+ +
+ + { + setPrefillData({ + ...prefill, + metadata: { + aiStructure: json ?? null, + ...prefill.metadata, + }, + }); + }} + /> + + {error && ( +
+ {error} +
+ )} + +
+ +
+
+ ); +} + diff --git a/components/admin/items/AdminItemCreateView.tsx b/components/admin/items/AdminItemCreateView.tsx new file mode 100644 index 0000000..1fc025e --- /dev/null +++ b/components/admin/items/AdminItemCreateView.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm"; + +interface AdminItemCreateViewProps { + locale: string; + categories: string[]; + serverOptions: string[]; +} + +export function AdminItemCreateView({ locale, categories, serverOptions }: AdminItemCreateViewProps) { + const router = useRouter(); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (payload: ItemFormPayload) => { + setSubmitting(true); + try { + const body = { + name: payload.name, + category: payload.category, + rarity: payload.rarity, + imageUrl: payload.imageUrl, + imageLocalPath: payload.imageLocalPath, + level: payload.level, + weight: payload.weight, + slots: payload.slots, + skill: payload.skill, + description: payload.description, + servers: payload.servers, + tags: payload.tags, + metadata: payload.metadata, + fields: payload.fields, + }; + + const response = await fetch("/api/admin/items", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => ({ error: "Erro ao criar item" })); + throw new Error(errorPayload?.error || "Falha ao criar item"); + } + + router.push(`/${locale}/admin?tab=items`); + router.refresh(); + } finally { + setSubmitting(false); + } + }; + + const handleCancel = () => { + router.push(`/${locale}/admin?tab=items`); + }; + + return ( +
+
+ +
+
+ ); +} + diff --git a/components/admin/items/AdminItemForm.tsx b/components/admin/items/AdminItemForm.tsx new file mode 100644 index 0000000..96488ae --- /dev/null +++ b/components/admin/items/AdminItemForm.tsx @@ -0,0 +1,1233 @@ +"use client"; + +import { useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface CategoryColumn { + name: string; + type: "TEXT" | "INTEGER" | "REAL" | "BOOLEAN"; + notNull?: boolean; + editable?: boolean; +} + +interface CategoryTemplate { + name: string; + columns: CategoryColumn[]; + imagePath: string; +} + +const BASE_COLUMN_NAMES = ["id", "nome", "image_url", "image_local_path"]; + +const categoriesCache: { + data: CategoryTemplate[] | null; + pending: Promise | null; +} = { + data: null, + pending: null, +}; + +function generateTempId() { + return Math.random().toString(36).slice(2, 10); +} + +export interface ItemFormPayload { + id?: number; + name: string; + category: string; + rarity?: string | null; + imageUrl?: string | null; + imageLocalPath?: string | null; + level?: number | null; + weight?: number | null; + slots?: number | null; + skill?: string | null; + description?: string | null; + servers: string[]; + tags: string[]; + metadata?: Record; + fields?: Record; +} + +interface ItemReference { + item_id: number; + item_name: string; + category: string; + rarity: string | null; + image_url: string | null; + image_local_path: string | null; + level: number | null; + weight: number | null; + slots: number | null; + skill: string | null; + description?: string | null; + servers: string[]; + tags: string[]; + metadata: Record; +} + +type AnalysisResult = + | { + table: string; + columns: Record; + } + | null; + +function parseCsv(input: string): string[] { + return input + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +function serializeCsv(list: string[]): string { + if (!Array.isArray(list) || list.length === 0) return ""; + return list.join(", "); +} + +function parseNumberInput(value: string): number | null { + if (!value) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +interface HeaderSlotProps { + title: string; + description: string; +} + +interface FooterSlotProps { + isEdit: boolean; + submitting?: boolean; + submitLabel: string; + onCancel?: () => void; + onSubmitClick: () => void; +} + +type PrefillPayload = Partial<{ + name: string; + category: string; + rarity: string | null; + imageUrl: string | null; + imageLocalPath: string | null; + level: number | string | null; + weight: number | string | null; + slots: number | string | null; + skill: string | null; + servers: string[] | string; + tags: string[] | string; + description: string | null; + metadata: Record; +}>; + +export interface AdminItemFormProps { + mode: "create" | "edit"; + item?: ItemReference; + categories: string[]; + serverOptions: string[]; + submitting?: boolean; + onSubmit: (payload: ItemFormPayload) => Promise; + onCancel?: () => void; + onSuccess?: () => void; + isActive?: boolean; + allowAiTools?: boolean; + prefillData?: PrefillPayload | null; + className?: string; + contentClassName?: string; + headerSlot?: (props: HeaderSlotProps) => React.ReactNode; + footerSlot?: (props: FooterSlotProps) => React.ReactNode; +} + +export function AdminItemForm({ + mode, + item, + categories, + serverOptions, + submitting, + onSubmit, + onCancel, + onSuccess, + isActive = true, + allowAiTools: allowAiToolsProp, + prefillData, + className, + contentClassName, + headerSlot, + footerSlot, +}: AdminItemFormProps) { + const isEdit = mode === "edit"; + const allowAiTools = allowAiToolsProp ?? !isEdit; + + const [name, setName] = useState(""); + const [category, setCategory] = useState(""); + const [rarity, setRarity] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [imageLocalPath, setImageLocalPath] = useState(""); + const [description, setDescription] = useState(""); + const [level, setLevel] = useState(""); + const [weight, setWeight] = useState(""); + const [slots, setSlots] = useState(""); + const [skill, setSkill] = useState(""); + const [serversCsv, setServersCsv] = useState(""); + const [tagsCsv, setTagsCsv] = useState(""); + + const [analysis, setAnalysis] = useState(null); + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysisError, setAnalysisError] = useState(null); + const [submitStatus, setSubmitStatus] = useState(null); + + const [categoryTemplates, setCategoryTemplates] = useState([]); + const [categoryLoading, setCategoryLoading] = useState(false); + const [categoryError, setCategoryError] = useState(null); + const [dynamicValues, setDynamicValues] = useState>({}); + const [prefilledMetadata, setPrefilledMetadata] = useState>({}); + const [showNewCategoryModal, setShowNewCategoryModal] = useState(false); + const [showNewFieldModal, setShowNewFieldModal] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(""); + const [newCategoryFields, setNewCategoryFields] = useState< + Array<{ id: string; name: string; type: CategoryColumn["type"]; notNull: boolean }> + >([]); + const [newCategoryError, setNewCategoryError] = useState(null); + const [newFieldName, setNewFieldName] = useState(""); + const [newFieldType, setNewFieldType] = useState("TEXT"); + const [newFieldNotNull, setNewFieldNotNull] = useState(false); + const [newFieldError, setNewFieldError] = useState(null); + + const dynamicInitializedRef = useRef(false); + const lastCategoryRef = useRef(null); + const [pendingPrefill, setPendingPrefill] = useState(null); + + const resetFormState = useCallback( + (templateList: CategoryTemplate[]) => { + if (isEdit && item) { + setName(item.item_name); + setCategory(item.category.toLowerCase()); + setRarity(item.rarity ?? ""); + setImageUrl(item.image_url ?? ""); + setImageLocalPath(item.image_local_path ?? ""); + setDescription(item.description ?? ""); + setLevel(item.level !== null && item.level !== undefined ? String(item.level) : ""); + setWeight(item.weight !== null && item.weight !== undefined ? String(item.weight) : ""); + setSlots(item.slots !== null && item.slots !== undefined ? String(item.slots) : ""); + setSkill(item.skill ?? ""); + setServersCsv(serializeCsv(item.servers)); + setTagsCsv(serializeCsv(item.tags)); + } else { + const defaultCategory = templateList[0]?.name ?? categories[0] ?? ""; + setName(""); + setCategory(defaultCategory.toLowerCase()); + setRarity(""); + setImageUrl(""); + setImageLocalPath(""); + setDescription(""); + setLevel(""); + setWeight(""); + setSlots(""); + setSkill(""); + setServersCsv(""); + setTagsCsv(""); + } + setSubmitStatus(null); + setAnalysis(null); + setAnalysisError(null); + setCategoryError(null); + setNewCategoryError(null); + setNewFieldError(null); + setNewCategoryFields([]); + dynamicInitializedRef.current = false; + lastCategoryRef.current = null; + setPrefilledMetadata({}); + }, + [isEdit, item, categories], + ); + + const fetchCategories = useCallback(async () => { + setCategoryError(null); + + if (categoriesCache.data) { + setCategoryTemplates(categoriesCache.data); + resetFormState(categoriesCache.data); + return; + } + + if (categoriesCache.pending) { + setCategoryLoading(true); + try { + const cached = await categoriesCache.pending; + if (cached) { + setCategoryTemplates(cached); + resetFormState(cached); + } + } catch (error) { + console.error(error); + setCategoryError(error instanceof Error ? error.message : "Erro ao carregar categorias"); + } finally { + setCategoryLoading(false); + } + return; + } + + setCategoryLoading(true); + const pendingPromise = (async () => { + try { + const response = await fetch("/api/admin/categories", { credentials: "include" }); + if (!response.ok) { + throw new Error("Falha ao carregar categorias"); + } + const data = await response.json(); + const templates = Array.isArray(data?.categories) ? data.categories : []; + categoriesCache.data = templates; + setCategoryTemplates(templates); + resetFormState(templates); + return templates; + } catch (error) { + console.error(error); + setCategoryError(error instanceof Error ? error.message : "Erro ao carregar categorias"); + throw error; + } finally { + categoriesCache.pending = null; + setCategoryLoading(false); + } + })(); + + categoriesCache.pending = pendingPromise; + await pendingPromise; + }, [resetFormState]); + + useEffect(() => { + if (!isActive) { + resetFormState(categoryTemplates); + return; + } + fetchCategories(); + }, [isActive, fetchCategories, categoryTemplates, resetFormState]); + + useEffect(() => { + if (!isActive) { + resetFormState(categoryTemplates); + return; + } + if (categoryTemplates.length === 0) { + resetFormState(categoryTemplates); + } + }, [isActive, categoryTemplates, resetFormState]); + + const getTemplateForCategory = useCallback( + (categoryName: string | null | undefined) => + categoryTemplates.find((template) => template.name === (categoryName ?? "").toLowerCase()), + [categoryTemplates], + ); + + const buildDynamicInitialValues = useCallback( + (template: CategoryTemplate | undefined, metadata?: Record) => { + if (!template) return {} as Record; + const values: Record = {}; + template.columns + .filter((column) => !BASE_COLUMN_NAMES.includes(column.name) && column.editable !== false) + .forEach((column) => { + const sourceValue = metadata ? metadata[column.name] : undefined; + if (sourceValue === undefined || sourceValue === null) { + values[column.name] = column.type === "BOOLEAN" ? false : ""; + return; + } + if (column.type === "BOOLEAN") { + values[column.name] = Boolean(Number(sourceValue)); + } else if (column.type === "INTEGER" || column.type === "REAL") { + values[column.name] = String(sourceValue); + } else { + values[column.name] = String(sourceValue); + } + }); + return values; + }, + [], + ); + + useEffect(() => { + if (!isActive) return; + const effectiveCategory = ( + category || + item?.category || + categoryTemplates[0]?.name || + "" + ).toLowerCase(); + if (!effectiveCategory) return; + + const template = getTemplateForCategory(effectiveCategory); + if (!template) return; + + if (!category) { + setCategory(template.name); + } + + const metadataSource = isEdit ? (item?.metadata as Record | undefined) : undefined; + + if (!dynamicInitializedRef.current || lastCategoryRef.current !== template.name) { + setDynamicValues(buildDynamicInitialValues(template, metadataSource)); + dynamicInitializedRef.current = true; + lastCategoryRef.current = template.name; + } + }, [ + isActive, + category, + item, + categoryTemplates, + getTemplateForCategory, + buildDynamicInitialValues, + isEdit, + ]); + + const handleAnalyze = async () => { + if (!allowAiTools) return; + if (!name.trim() || !description.trim()) { + setAnalysisError("Informe nome e descrição para gerar o JSON."); + return; + } + + setAnalysisError(null); + setAnalysis(null); + setAnalysisLoading(true); + try { + const res = await fetch("/api/admin/items/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim(), + scope: serverOptions.length > 0 ? "server" : "global", + server: serverOptions[0] ?? null, + }), + }); + + if (!res.ok) { + throw new Error("Falha ao comunicar com o serviço de IA."); + } + + const data = await res.json(); + setAnalysis(data.structure ?? null); + } catch (error) { + console.error(error); + setAnalysisError( + error instanceof Error ? error.message : "Erro desconhecido ao gerar JSON.", + ); + } finally { + setAnalysisLoading(false); + } + }; + + const handleDynamicValueChange = useCallback( + (column: CategoryColumn, value: string | boolean | null) => { + setDynamicValues((prev) => ({ + ...prev, + [column.name]: value, + })); + }, + [], + ); + + const handleCategoryChange = useCallback((value: string) => { + dynamicInitializedRef.current = false; + lastCategoryRef.current = null; + setCategory(value.toLowerCase()); + }, []); + + const handleAddCategoryField = useCallback(() => { + setNewCategoryFields((prev) => [ + ...prev, + { id: generateTempId(), name: "", type: "TEXT", notNull: false }, + ]); + }, []); + + const handleUpdateCategoryField = useCallback( + (id: string, key: "name" | "type" | "notNull", value: string | boolean) => { + setNewCategoryFields((prev) => + prev.map((field) => + field.id === id + ? { + ...field, + [key]: key === "notNull" ? Boolean(value) : value, + } + : field, + ), + ); + }, + [], + ); + + const handleRemoveCategoryField = useCallback((id: string) => { + setNewCategoryFields((prev) => prev.filter((field) => field.id !== id)); + }, []); + + const handleCreateCategory = useCallback(async () => { + if (!newCategoryName.trim()) { + setNewCategoryError("Informe o nome da categoria"); + return; + } + + try { + setNewCategoryError(null); + const payload = { + name: newCategoryName.trim().toLowerCase(), + columns: newCategoryFields + .filter((field) => field.name.trim()) + .map((field) => ({ + name: field.name.trim().toLowerCase(), + type: field.type, + notNull: field.notNull, + })), + }; + + const res = await fetch("/api/admin/categories", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const error = await res.json().catch(() => null); + throw new Error(error?.error || "Falha ao criar categoria"); + } + + categoriesCache.data = null; + await fetchCategories(); + handleCategoryChange(payload.name); + setShowNewCategoryModal(false); + setNewCategoryFields([]); + } catch (error) { + console.error(error); + setNewCategoryError( + error instanceof Error ? error.message : "Erro ao criar categoria", + ); + } + }, [ + newCategoryName, + newCategoryFields, + fetchCategories, + handleCategoryChange, + ]); + + const handleAddFieldToCategory = useCallback(async () => { + if (!category) { + setNewFieldError("Selecione uma categoria"); + return; + } + if (!newFieldName.trim()) { + setNewFieldError("Informe o nome do campo"); + return; + } + + try { + setNewFieldError(null); + const payload = { + name: newFieldName.trim().toLowerCase(), + type: newFieldType, + notNull: newFieldNotNull, + }; + + const res = await fetch(`/api/admin/categories/${category}/fields`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const error = await res.json().catch(() => null); + throw new Error(error?.error || "Falha ao adicionar campo"); + } + + categoriesCache.data = null; + await fetchCategories(); + dynamicInitializedRef.current = false; + lastCategoryRef.current = null; + setShowNewFieldModal(false); + } catch (error) { + console.error(error); + setNewFieldError( + error instanceof Error ? error.message : "Erro ao adicionar campo", + ); + } + }, [category, newFieldName, newFieldType, newFieldNotNull, fetchCategories]); + + const serverSuggestions = useMemo(() => { + if (serverOptions.length === 0) return ""; + return serverOptions.join(", "); + }, [serverOptions]); + + const activeTemplate = useMemo(() => getTemplateForCategory(category), [category, getTemplateForCategory]); + + const dynamicColumns = useMemo( + () => + activeTemplate?.columns.filter( + (column) => !BASE_COLUMN_NAMES.includes(column.name) && column.editable !== false, + ) ?? [], + [activeTemplate], + ); + + const submitLabel = isEdit ? "Salvar alterações" : "Criar item"; + + const handlePrimarySubmit = useCallback(async () => { + if (!name.trim()) { + setSubmitStatus("Informe o nome do item."); + return; + } + + if (!category.trim()) { + setSubmitStatus("Informe a categoria do item."); + return; + } + + setSubmitStatus(null); + try { + const metadataPayload: Record = { ...prefilledMetadata }; + if (allowAiTools && analysis) { + metadataPayload.aiStructure = analysis; + } + for (const [key, value] of Object.entries(dynamicValues)) { + metadataPayload[key] = value; + } + + await onSubmit({ + id: item?.item_id, + name: name.trim(), + category: category.trim().toLowerCase(), + rarity: rarity.trim() || null, + imageUrl: imageUrl.trim() || null, + imageLocalPath: imageLocalPath.trim() || null, + description: description.trim() || null, + level: parseNumberInput(level), + weight: parseNumberInput(weight), + slots: parseNumberInput(slots), + skill: skill.trim() || null, + servers: parseCsv(serversCsv), + tags: parseCsv(tagsCsv), + metadata: metadataPayload, + fields: dynamicValues, + }); + + setSubmitStatus(isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso."); + onSuccess?.(); + + if (!isEdit) { + resetFormState(categoryTemplates); + } + } catch (error) { + console.error(error); + setSubmitStatus(error instanceof Error ? error.message : "Erro ao salvar. Tente novamente."); + } + }, [ + name, + category, + rarity, + imageUrl, + imageLocalPath, + description, + level, + weight, + slots, + skill, + serversCsv, + tagsCsv, + dynamicValues, + allowAiTools, + analysis, + onSubmit, + item, + isEdit, + onSuccess, + categoryTemplates, + resetFormState, + prefilledMetadata, + ]); + + const title = isEdit ? "Editar item" : "Novo item"; + const descriptionText = allowAiTools + ? "Preencha os dados do item unificado. Utilize a IA para gerar um JSON de referência, se desejar." + : "Preencha os dados do item unificado. Ajuste as sugestões geradas pela IA acima para concluir o cadastro."; + + const handleFormSubmit = (event: React.FormEvent) => { + event.preventDefault(); + void handlePrimarySubmit(); + }; + + const formClassName = className ?? "flex h-full flex-col"; + const bodyClassName = contentClassName ?? "flex-1 min-h-0 overflow-y-auto px-6 py-4"; + + const applyPrefill = useCallback( + (prefill: PrefillPayload) => { + if (prefill.name !== undefined) setName(prefill.name ?? ""); + if (prefill.description !== undefined) setDescription(prefill.description ?? ""); + if (prefill.rarity !== undefined) setRarity(prefill.rarity ?? ""); + if (prefill.imageUrl !== undefined) setImageUrl(prefill.imageUrl ?? ""); + if (prefill.imageLocalPath !== undefined) setImageLocalPath(prefill.imageLocalPath ?? ""); + if (prefill.level !== undefined) setLevel(prefill.level ? String(prefill.level) : ""); + if (prefill.weight !== undefined) setWeight(prefill.weight ? String(prefill.weight) : ""); + if (prefill.slots !== undefined) setSlots(prefill.slots ? String(prefill.slots) : ""); + if (prefill.skill !== undefined) setSkill(prefill.skill ?? ""); + + if (prefill.servers !== undefined) { + const serversValue = Array.isArray(prefill.servers) ? prefill.servers.join(", ") : prefill.servers ?? ""; + setServersCsv(serversValue); + } + + if (prefill.tags !== undefined) { + const tagsValue = Array.isArray(prefill.tags) ? prefill.tags.join(", ") : prefill.tags ?? ""; + setTagsCsv(tagsValue); + } + + let targetCategory = category; + const metadataCategory = + typeof prefill.metadata?.category === "string" ? prefill.metadata.category.toLowerCase() : null; + if (prefill.category) { + targetCategory = prefill.category.toLowerCase(); + setCategory(targetCategory); + dynamicInitializedRef.current = false; + lastCategoryRef.current = null; + } else if (metadataCategory) { + targetCategory = metadataCategory; + setCategory(targetCategory); + dynamicInitializedRef.current = false; + lastCategoryRef.current = null; + } + + if (prefill.metadata && typeof prefill.metadata === "object") { + setPrefilledMetadata(prefill.metadata); + const template = getTemplateForCategory(targetCategory); + if (template) { + setDynamicValues(buildDynamicInitialValues(template, prefill.metadata)); + } + } + }, + [ + buildDynamicInitialValues, + category, + getTemplateForCategory, + ], + ); + + useEffect(() => { + if (prefillData) { + setPendingPrefill(prefillData); + } + }, [prefillData]); + + useEffect(() => { + if (!pendingPrefill) return; + if (categoryTemplates.length === 0) return; + applyPrefill(pendingPrefill); + setPendingPrefill(null); + }, [applyPrefill, pendingPrefill, categoryTemplates]); + + return ( + <> +
+ {headerSlot?.({ title, description: descriptionText }) ?? + ( +
+

{title}

+

{descriptionText}

+
+ )} + +
+
+
+
+ + setName(event.target.value)} + /> +
+ +
+
+ +
+ + + +
+ {categoryError &&

{categoryError}

} + {isEdit && ( +

+ A categoria existente do item não pode ser alterada neste fluxo. +

+ )} +
+
+ + setRarity(event.target.value)} + /> +
+
+ +
+
+ + setImageUrl(event.target.value)} + /> +
+
+ + setImageLocalPath(event.target.value)} + /> +
+
+ +
+
+ + setLevel(event.target.value)} + /> +
+
+ + setWeight(event.target.value)} + /> +
+
+ +
+
+ + setSlots(event.target.value)} + /> +
+
+ + setSkill(event.target.value)} + /> +
+
+ + {dynamicColumns.length > 0 && ( +
+
+ +

+ Defina valores adicionais conforme o modelo da categoria selecionada. +

+
+
+ {dynamicColumns.map((column) => { + const value = dynamicValues[column.name]; + if (column.type === "BOOLEAN") { + return ( +
+ + handleDynamicValueChange(column, checked === true) + } + /> + +
+ ); + } + + const inputType = column.type === "TEXT" ? "text" : "number"; + const step = column.type === "REAL" ? "0.01" : "1"; + + return ( +
+ + + handleDynamicValueChange(column, event.target.value) + } + /> +
+ ); + })} +
+
+ )} + +
+ + setServersCsv(event.target.value)} + /> + {serverSuggestions && ( +

+ Sugestões cadastradas: {serverSuggestions} +

+ )} +
+ +
+ + setTagsCsv(event.target.value)} + /> +
+ +
+ +