feat: add user authentication UI to header component
Add login and register dialogs, user dropdown menu with profile link and logout option, and integrate with session management hook. Prevent hydration mismatches by conditionally rendering auth elements after mount. Update package-lock.json with new @esbuild dependencies for build tooling.
This commit is contained in:
1 parent
e641138887
commit
55eebde0ba
55 files changed
+7353
-7
No files matched your search
@@ -0,0 +1,199 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
checkRateLimit,
|
||||
getClientIP,
|
||||
createRateLimitResponse,
|
||||
} from "@/lib/rate-limiter";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { loginSchema } from "@/lib/validation-schemas";
|
||||
import { comparePassword } from "@/lib/auth/password";
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
} from "@/lib/auth/jwt";
|
||||
import {
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
updateLastLogin,
|
||||
} from "@/lib/db/queries/users";
|
||||
import { setSession } from "@/lib/redis/session";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = getClientIP(request);
|
||||
const rateLimit = checkRateLimit(ip, "/api/auth/login");
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit.resetAt);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
secureLogger.info("📥 POST /api/auth/login - Recebido", {
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = loginSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { emailOrUsername, password } = validationResult.data;
|
||||
|
||||
// Buscar usuário por email ou username
|
||||
const user =
|
||||
(await getUserByEmail(emailOrUsername)) ||
|
||||
(await getUserByUsername(emailOrUsername));
|
||||
|
||||
if (!user) {
|
||||
secureLogger.warn("⚠️ Tentativa de login com credenciais inválidas", {
|
||||
emailOrUsername: secureLogger.mask({ emailOrUsername }),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Email/username ou senha incorretos",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se usuário está ativo
|
||||
if (!user.is_active) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Conta desativada. Entre em contato com o suporte.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar senha
|
||||
if (!user.password_hash) {
|
||||
secureLogger.error("❌ Usuário sem password_hash", {
|
||||
userId: user.user_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro interno. Tente novamente mais tarde.",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordMatch = await comparePassword(password, user.password_hash);
|
||||
|
||||
if (!passwordMatch) {
|
||||
secureLogger.warn("⚠️ Senha incorreta", {
|
||||
userId: user.user_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Email/username ou senha incorretos",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar último login
|
||||
await updateLastLogin(user.user_id);
|
||||
|
||||
// Gerar tokens
|
||||
const accessToken = generateAccessToken(user.user_id, user.username);
|
||||
const refreshToken = generateRefreshToken(user.user_id, user.username);
|
||||
|
||||
// Criar sessão no Redis
|
||||
const sessionId = randomUUID();
|
||||
await setSession(sessionId, {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
accessToken,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Criar resposta
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
accountLevel: user.account_level,
|
||||
hasDonated: user.has_donated,
|
||||
},
|
||||
accessToken,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
// Adicionar refresh token e sessionId como cookies httpOnly
|
||||
response.cookies.set("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||
path: "/",
|
||||
});
|
||||
|
||||
response.cookies.set("sessionId", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||
path: "/",
|
||||
});
|
||||
|
||||
secureLogger.info("✅ Login realizado com sucesso", {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao fazer login", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { requireAuth } from "@/lib/auth/middleware";
|
||||
import { getUserById } from "@/lib/db/queries/users";
|
||||
import { getUserCharacters } from "@/lib/db/queries/characters";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
// Se retornou NextResponse, é um erro de autenticação
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
// Buscar dados do usuário
|
||||
const user = await getUserById(auth.userId);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Usuário não encontrado",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Buscar personagens do usuário
|
||||
const characters = await getUserCharacters(auth.userId);
|
||||
|
||||
secureLogger.info("✅ Perfil do usuário recuperado", {
|
||||
userId: auth.userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
cpf: user.cpf,
|
||||
accountLevel: user.account_level,
|
||||
hasDonated: user.has_donated,
|
||||
whatsappNotifications: user.whatsapp_notifications,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
lastLogin: user.last_login,
|
||||
isActive: user.is_active,
|
||||
isVerified: user.is_verified,
|
||||
},
|
||||
characters: characters.map((char) => ({
|
||||
characterId: char.character_id,
|
||||
characterName: char.character_name,
|
||||
serverName: char.server_name,
|
||||
isMain: char.is_main,
|
||||
verified: char.verified,
|
||||
createdAt: char.created_at,
|
||||
})),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao buscar perfil do usuário", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
checkRateLimit,
|
||||
getClientIP,
|
||||
createRateLimitResponse,
|
||||
} from "@/lib/rate-limiter";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { verifyRefreshToken, generateAccessToken } from "@/lib/auth/jwt";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = getClientIP(request);
|
||||
const rateLimit = checkRateLimit(ip, "/api/auth/refresh");
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit.resetAt);
|
||||
}
|
||||
|
||||
// Extrair refresh token do cookie
|
||||
const refreshToken = request.cookies.get("refreshToken")?.value;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Refresh token não fornecido",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validar refresh token
|
||||
const payload = verifyRefreshToken(refreshToken);
|
||||
|
||||
if (!payload) {
|
||||
// Token inválido - limpar cookie
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Refresh token inválido ou expirado",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
response.cookies.delete("refreshToken");
|
||||
return response;
|
||||
}
|
||||
|
||||
// Gerar novo access token
|
||||
const accessToken = generateAccessToken(payload.userId, payload.username);
|
||||
|
||||
secureLogger.info("✅ Token renovado com sucesso", {
|
||||
userId: payload.userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
accessToken,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao renovar token", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
checkRateLimit,
|
||||
getClientIP,
|
||||
createRateLimitResponse,
|
||||
} from "@/lib/rate-limiter";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { registerSchema } from "@/lib/validation-schemas";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
} from "@/lib/auth/jwt";
|
||||
import {
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
} from "@/lib/db/queries/users";
|
||||
import {
|
||||
isServerActive,
|
||||
incrementUsersForServers,
|
||||
} from "@/lib/db/queries/servers";
|
||||
import { setSession } from "@/lib/redis/session";
|
||||
import { randomUUID } from "crypto";
|
||||
import { getPool } from "@/lib/db";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = getClientIP(request);
|
||||
const rateLimit = checkRateLimit(ip, "/api/auth/register");
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit.resetAt);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
secureLogger.info("📥 POST /api/auth/register - Recebido", {
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = registerSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
secureLogger.error("❌ Validação falhou", {
|
||||
errors: JSON.stringify(errors),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = validationResult.data;
|
||||
|
||||
// Verificar duplicidade: email
|
||||
const existingEmail = await getUserByEmail(validatedData.email);
|
||||
if (existingEmail) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Email já está em uso",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar duplicidade: username
|
||||
const existingUsername = await getUserByUsername(validatedData.username);
|
||||
if (existingUsername) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Username já está em uso",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validar servidores: verificar se existem e estão ativos
|
||||
const serverNames = validatedData.servers.map((s) => s.serverName);
|
||||
const uniqueServerNames = [...new Set(serverNames)]; // Remover duplicatas
|
||||
|
||||
for (const serverName of uniqueServerNames) {
|
||||
const isActive = await isServerActive(serverName);
|
||||
if (!isActive) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Servidor "${serverName}" não existe ou não está ativo`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hash da senha
|
||||
const passwordHash = await hashPassword(validatedData.password);
|
||||
|
||||
// Criar usuário e personagens em transação atômica
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
let newUser: { userId: number; username: string; email: string };
|
||||
const createdCharacters: Array<{ characterName: string; serverName: string }> = [];
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Criar usuário
|
||||
const userResult = await client.query<{
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}>(
|
||||
`
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, phone, cpf)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING user_id, username, email
|
||||
`,
|
||||
[
|
||||
validatedData.username,
|
||||
validatedData.email,
|
||||
passwordHash,
|
||||
validatedData.firstName,
|
||||
validatedData.lastName,
|
||||
validatedData.phone || null,
|
||||
null, // CPF não é mais obrigatório
|
||||
]
|
||||
);
|
||||
|
||||
newUser = {
|
||||
userId: userResult.rows[0].user_id,
|
||||
username: userResult.rows[0].username,
|
||||
email: userResult.rows[0].email,
|
||||
};
|
||||
|
||||
// Criar personagens (primeiro é main por padrão)
|
||||
for (let i = 0; i < validatedData.servers.length; i++) {
|
||||
const serverData = validatedData.servers[i];
|
||||
const isMain = i === 0 || serverData.isMain === true;
|
||||
|
||||
// Se este personagem será main e não é o primeiro, desmarcar outros
|
||||
if (isMain && i > 0) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE user_characters
|
||||
SET is_main = FALSE
|
||||
WHERE user_id = $1
|
||||
`,
|
||||
[newUser.userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Inserir personagem
|
||||
const charResult = await client.query<{
|
||||
character_id: number;
|
||||
character_name: string;
|
||||
server_name: string;
|
||||
}>(
|
||||
`
|
||||
INSERT INTO user_characters (user_id, character_name, server_name, is_main)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING character_id, character_name, server_name
|
||||
`,
|
||||
[newUser.userId, serverData.characterName, serverData.serverName, isMain]
|
||||
);
|
||||
|
||||
createdCharacters.push({
|
||||
characterName: charResult.rows[0].character_name,
|
||||
serverName: charResult.rows[0].server_name,
|
||||
});
|
||||
}
|
||||
|
||||
// Incrementar total_users dos servidores
|
||||
await incrementUsersForServers(uniqueServerNames);
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Gerar tokens
|
||||
const accessToken = generateAccessToken(newUser.userId, newUser.username);
|
||||
const refreshToken = generateRefreshToken(
|
||||
newUser.userId,
|
||||
newUser.username
|
||||
);
|
||||
|
||||
// Criar sessão no Redis
|
||||
const sessionId = randomUUID();
|
||||
await setSession(sessionId, {
|
||||
userId: newUser.userId,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
accessToken,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Criar resposta
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
userId: newUser.userId,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
},
|
||||
accessToken,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
// Adicionar refresh token e sessionId como cookies httpOnly
|
||||
response.cookies.set("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||
path: "/",
|
||||
});
|
||||
|
||||
response.cookies.set("sessionId", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 dias
|
||||
path: "/",
|
||||
});
|
||||
|
||||
secureLogger.info("✅ Usuário registrado com sucesso", {
|
||||
userId: newUser.userId,
|
||||
username: newUser.username,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao registrar usuário", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { getSession, refreshSession, deleteSession } from "@/lib/redis/session";
|
||||
import { verifyAccessToken } from "@/lib/auth/jwt";
|
||||
|
||||
/**
|
||||
* GET /api/auth/session
|
||||
* Verifica e retorna dados da sessão atual
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Buscar sessionId do cookie
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("sessionId")?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: "Nenhuma sessão encontrada",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Buscar sessão no Redis
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Limpar cookie se sessão não existe
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: "Sessão expirada ou inválida",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
response.cookies.delete("sessionId");
|
||||
return response;
|
||||
}
|
||||
|
||||
// Verificar se o access token ainda é válido
|
||||
const tokenPayload = verifyAccessToken(session.accessToken);
|
||||
|
||||
if (!tokenPayload) {
|
||||
// Token expirado - remover sessão
|
||||
await deleteSession(sessionId);
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: "Token expirado",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
response.cookies.delete("sessionId");
|
||||
return response;
|
||||
}
|
||||
|
||||
// Atualizar TTL da sessão
|
||||
await refreshSession(sessionId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
authenticated: true,
|
||||
user: {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao verificar sessão", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/session
|
||||
* Encerra a sessão atual
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("sessionId")?.value;
|
||||
|
||||
if (sessionId) {
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Sessão encerrada",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
response.cookies.delete("sessionId");
|
||||
return response;
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao encerrar sessão", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { requireAuth, optionalAuth } from "@/lib/auth/middleware";
|
||||
import {
|
||||
getListingById,
|
||||
updateListing,
|
||||
deleteListing,
|
||||
} from "@/lib/db/queries/listings";
|
||||
import { updateListingSchema } from "@/lib/validation-schemas";
|
||||
|
||||
/**
|
||||
* GET /api/listings/[id]
|
||||
* Busca anúncio por ID
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const listingId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(listingId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de anúncio inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const listing = await getListingById(listingId);
|
||||
|
||||
if (!listing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Anúncio não encontrado",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
listing,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao buscar anúncio", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao buscar anúncio",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/listings/[id]
|
||||
* Atualiza anúncio (apenas dono)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Autenticação obrigatória
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const { id } = await params;
|
||||
const listingId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(listingId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de anúncio inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se o anúncio existe e pertence ao usuário
|
||||
const listing = await getListingById(listingId);
|
||||
|
||||
if (!listing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Anúncio não encontrado",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (listing.seller_id !== auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Você não tem permissão para editar este anúncio",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = updateListingSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = validationResult.data;
|
||||
|
||||
// Atualizar anúncio
|
||||
const updatedListing = await updateListing(listingId, validatedData);
|
||||
|
||||
if (!updatedListing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao atualizar anúncio",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
secureLogger.info("✅ Anúncio atualizado com sucesso", {
|
||||
listingId,
|
||||
userId: auth.userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
listing: updatedListing,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao atualizar anúncio", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao atualizar anúncio",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/listings/[id]
|
||||
* Cancela anúncio (apenas dono)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Autenticação obrigatória
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const { id } = await params;
|
||||
const listingId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(listingId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de anúncio inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se o anúncio existe e pertence ao usuário
|
||||
const listing = await getListingById(listingId);
|
||||
|
||||
if (!listing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Anúncio não encontrado",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (listing.seller_id !== auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Você não tem permissão para cancelar este anúncio",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cancelar anúncio (soft delete)
|
||||
const deleted = await deleteListing(listingId);
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao cancelar anúncio",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
secureLogger.info("✅ Anúncio cancelado com sucesso", {
|
||||
listingId,
|
||||
userId: auth.userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "Anúncio cancelado com sucesso",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao cancelar anúncio", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao cancelar anúncio",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { incrementViews } from "@/lib/db/queries/listings";
|
||||
|
||||
/**
|
||||
* POST /api/listings/[id]/views
|
||||
* Incrementa contador de views de um anúncio
|
||||
* Não requer autenticação (público)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const listingId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(listingId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de anúncio inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Incrementar views
|
||||
await incrementViews(listingId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "View incrementada",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao incrementar views", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao incrementar views",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
checkRateLimit,
|
||||
getClientIP,
|
||||
createRateLimitResponse,
|
||||
} from "@/lib/rate-limiter";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { createListingSchema } from "@/lib/validation-schemas";
|
||||
import { requireAuth, optionalAuth } from "@/lib/auth/middleware";
|
||||
import {
|
||||
getListings,
|
||||
createListing,
|
||||
getActiveListings,
|
||||
} from "@/lib/db/queries/listings";
|
||||
import { isServerActive } from "@/lib/db/queries/servers";
|
||||
|
||||
/**
|
||||
* GET /api/listings
|
||||
* Lista anúncios com filtros opcionais
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const filters = {
|
||||
status: searchParams.get("status") as
|
||||
| "active"
|
||||
| "sold"
|
||||
| "cancelled"
|
||||
| "in_escrow"
|
||||
| "expired"
|
||||
| undefined,
|
||||
server_name: searchParams.get("server_name") || undefined,
|
||||
item_id: searchParams.get("item_id")
|
||||
? parseInt(searchParams.get("item_id")!, 10)
|
||||
: undefined,
|
||||
seller_id: searchParams.get("seller_id")
|
||||
? parseInt(searchParams.get("seller_id")!, 10)
|
||||
: undefined,
|
||||
is_auction:
|
||||
searchParams.get("is_auction") === "true"
|
||||
? true
|
||||
: searchParams.get("is_auction") === "false"
|
||||
? false
|
||||
: undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
page: searchParams.get("page")
|
||||
? parseInt(searchParams.get("page")!, 10)
|
||||
: 1,
|
||||
pageSize: searchParams.get("pageSize")
|
||||
? parseInt(searchParams.get("pageSize")!, 10)
|
||||
: 20,
|
||||
sortBy: (searchParams.get("sortBy") ||
|
||||
"created_at") as
|
||||
| "created_at"
|
||||
| "price_pix"
|
||||
| "price_tibia_coins"
|
||||
| "price_gold_coins"
|
||||
| "views_count",
|
||||
sortOrder: (searchParams.get("sortOrder") || "DESC") as "ASC" | "DESC",
|
||||
};
|
||||
|
||||
// Se não especificar status, usar apenas ativos por padrão
|
||||
const listingsData = filters.status
|
||||
? await getListings(filters)
|
||||
: await getActiveListings(filters);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
listings: listingsData.listings,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: listingsData.total,
|
||||
totalPages: Math.ceil(listingsData.total / filters.pageSize),
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao buscar anúncios", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao buscar anúncios",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/listings
|
||||
* Cria um novo anúncio (requer autenticação)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = getClientIP(request);
|
||||
const rateLimit = checkRateLimit(ip, "/api/listings");
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return createRateLimitResponse(rateLimit.resetAt);
|
||||
}
|
||||
|
||||
// Autenticação obrigatória
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
secureLogger.info("📥 POST /api/listings - Recebido", {
|
||||
userId: auth.userId,
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = createListingSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = validationResult.data;
|
||||
|
||||
// Validar se o servidor existe e está ativo
|
||||
const serverActive = await isServerActive(validatedData.server_name);
|
||||
if (!serverActive) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Servidor "${validatedData.server_name}" não existe ou não está ativo`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Criar anúncio
|
||||
const listing = await createListing({
|
||||
seller_id: auth.userId,
|
||||
item_id: validatedData.item_id,
|
||||
item_name: validatedData.item_name,
|
||||
quantity: validatedData.quantity,
|
||||
server_name: validatedData.server_name,
|
||||
price_pix: validatedData.price_pix ?? undefined,
|
||||
price_tibia_coins: validatedData.price_tibia_coins ?? undefined,
|
||||
price_gold_coins: validatedData.price_gold_coins ?? undefined,
|
||||
is_auction: validatedData.is_auction,
|
||||
auction_ends_at: validatedData.auction_ends_at ?? undefined,
|
||||
description: validatedData.description ?? undefined,
|
||||
});
|
||||
|
||||
secureLogger.info("✅ Anúncio criado com sucesso", {
|
||||
listingId: listing.listing_id,
|
||||
userId: auth.userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
listing,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao criar anúncio", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao criar anúncio",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getActiveServers } from "@/lib/db/queries/servers";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
/**
|
||||
* GET /api/servers
|
||||
* Lista todos os servidores ativos disponíveis
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const servers = await getActiveServers();
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
servers: servers.map((server) => ({
|
||||
serverId: server.server_id,
|
||||
serverName: server.server_name,
|
||||
serverType: server.server_type,
|
||||
location: server.location,
|
||||
isActive: server.is_active,
|
||||
stats: {
|
||||
totalListings: server.total_listings,
|
||||
totalUsers: server.total_users,
|
||||
totalSubscribers: server.total_subscribers,
|
||||
totalTransactions: server.total_transactions,
|
||||
totalVolume: Number(server.total_volume),
|
||||
averageListingPrice: Number(server.average_listing_price),
|
||||
lastActivityAt: server.last_activity_at,
|
||||
},
|
||||
})),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao buscar servidores", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Erro ao buscar servidores",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { getUserById } from "@/lib/db/queries/users";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const userId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(userId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de usuário inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Usuário não encontrado",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Retornar apenas dados públicos (sem informações sensíveis)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
accountLevel: user.account_level,
|
||||
hasDonated: user.has_donated,
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Erro ao buscar perfil público", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { requireAuth } from "@/lib/auth/middleware";
|
||||
import {
|
||||
updateCharacter,
|
||||
deleteCharacter,
|
||||
getCharacterById,
|
||||
} from "@/lib/db/queries/characters";
|
||||
import { characterSchema } from "@/lib/validation-schemas";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const { id } = await params;
|
||||
const characterId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(characterId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de personagem inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = characterSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = validationResult.data;
|
||||
|
||||
// Atualizar personagem (verifica ownership internamente)
|
||||
const updatedCharacter = await updateCharacter(
|
||||
characterId,
|
||||
auth.userId,
|
||||
{
|
||||
character_name: validatedData.characterName,
|
||||
server_name: validatedData.serverName,
|
||||
is_main: validatedData.isMain,
|
||||
}
|
||||
);
|
||||
|
||||
secureLogger.info("✅ Personagem atualizado com sucesso", {
|
||||
userId: auth.userId,
|
||||
characterId: updatedCharacter.character_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
character: {
|
||||
characterId: updatedCharacter.character_id,
|
||||
characterName: updatedCharacter.character_name,
|
||||
serverName: updatedCharacter.server_name,
|
||||
isMain: updatedCharacter.is_main,
|
||||
verified: updatedCharacter.verified,
|
||||
createdAt: updatedCharacter.created_at,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Verificar se é erro de permissão
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("não tem permissão")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao atualizar personagem", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const { id } = await params;
|
||||
const characterId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(characterId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "ID de personagem inválido",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Remover personagem (verifica ownership internamente)
|
||||
await deleteCharacter(characterId, auth.userId);
|
||||
|
||||
secureLogger.info("✅ Personagem removido com sucesso", {
|
||||
userId: auth.userId,
|
||||
characterId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Verificar se é erro de permissão
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("não tem permissão")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao remover personagem", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { requireAuth } from "@/lib/auth/middleware";
|
||||
import { getUserCharacters, createCharacter } from "@/lib/db/queries/characters";
|
||||
import { characterSchema } from "@/lib/validation-schemas";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
// Listar personagens do usuário
|
||||
const characters = await getUserCharacters(auth.userId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
characters: characters.map((char) => ({
|
||||
characterId: char.character_id,
|
||||
characterName: char.character_name,
|
||||
serverName: char.server_name,
|
||||
isMain: char.is_main,
|
||||
verified: char.verified,
|
||||
createdAt: char.created_at,
|
||||
})),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao listar personagens", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = characterSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = validationResult.data;
|
||||
|
||||
// Criar personagem
|
||||
const newCharacter = await createCharacter(auth.userId, {
|
||||
character_name: validatedData.characterName,
|
||||
server_name: validatedData.serverName,
|
||||
is_main: validatedData.isMain,
|
||||
});
|
||||
|
||||
secureLogger.info("✅ Personagem criado com sucesso", {
|
||||
userId: auth.userId,
|
||||
characterId: newCharacter.character_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
character: {
|
||||
characterId: newCharacter.character_id,
|
||||
characterName: newCharacter.character_name,
|
||||
serverName: newCharacter.server_name,
|
||||
isMain: newCharacter.is_main,
|
||||
verified: newCharacter.verified,
|
||||
createdAt: newCharacter.created_at,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Verificar se é erro de constraint (personagem duplicado)
|
||||
if (error instanceof Error && error.message.includes("UNIQUE")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Este personagem já está cadastrado neste servidor",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao criar personagem", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { requireAuth } from "@/lib/auth/middleware";
|
||||
import { getUserById, updateUser } from "@/lib/db/queries/users";
|
||||
import { getUserCharacters } from "@/lib/db/queries/characters";
|
||||
import { updateUserSchema } from "@/lib/validation-schemas";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
// Buscar dados do usuário
|
||||
const user = await getUserById(auth.userId);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Usuário não encontrado",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Buscar personagens do usuário
|
||||
const characters = await getUserCharacters(auth.userId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
cpf: user.cpf,
|
||||
accountLevel: user.account_level,
|
||||
hasDonated: user.has_donated,
|
||||
whatsappNotifications: user.whatsapp_notifications,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
lastLogin: user.last_login,
|
||||
isActive: user.is_active,
|
||||
isVerified: user.is_verified,
|
||||
},
|
||||
characters: characters.map((char) => ({
|
||||
characterId: char.character_id,
|
||||
characterName: char.character_name,
|
||||
serverName: char.server_name,
|
||||
isMain: char.is_main,
|
||||
verified: char.verified,
|
||||
createdAt: char.created_at,
|
||||
})),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao buscar perfil do usuário", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
// Middleware de autenticação
|
||||
const authResult = await requireAuth(request);
|
||||
|
||||
if (authResult instanceof NextResponse) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const auth = authResult;
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Validação com Zod
|
||||
const validationResult = updateUserSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.issues.map((err) => ({
|
||||
field: err.path.join("."),
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Dados de entrada inválidos",
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = validationResult.data;
|
||||
|
||||
// Atualizar usuário
|
||||
const updatedUser = await updateUser(auth.userId, {
|
||||
first_name: validatedData.firstName,
|
||||
last_name: validatedData.lastName,
|
||||
phone: validatedData.phone,
|
||||
whatsapp_notifications: validatedData.whatsappNotifications,
|
||||
});
|
||||
|
||||
secureLogger.info("✅ Perfil atualizado com sucesso", {
|
||||
userId: auth.userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
userId: updatedUser.user_id,
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
firstName: updatedUser.first_name,
|
||||
lastName: updatedUser.last_name,
|
||||
phone: updatedUser.phone,
|
||||
cpf: updatedUser.cpf,
|
||||
accountLevel: updatedUser.account_level,
|
||||
hasDonated: updatedUser.has_donated,
|
||||
whatsappNotifications: updatedUser.whatsapp_notifications,
|
||||
createdAt: updatedUser.created_at,
|
||||
updatedAt: updatedUser.updated_at,
|
||||
lastLogin: updatedUser.last_login,
|
||||
isActive: updatedUser.is_active,
|
||||
isVerified: updatedUser.is_verified,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Se for NextResponse (erro de autenticação), re-lançar
|
||||
if (error instanceof NextResponse) {
|
||||
return error;
|
||||
}
|
||||
|
||||
secureLogger.error("❌ Erro ao atualizar perfil", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Erro interno",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,37 @@ import { WordRotate } from "@/components/ui/word-rotate";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
import { MobileMenu } from "./mobile-menu";
|
||||
import { LoginDialog } from "./auth/login-dialog";
|
||||
import { RegisterDialog } from "./auth/register-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations("header.nav");
|
||||
const locale = useLocale();
|
||||
const { user, isAuthenticated, isLoading, logout } = useSession();
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Evitar hidratação mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const links = [
|
||||
{ href: `/${locale}/library`, label: "Library" },
|
||||
@@ -27,6 +51,10 @@ export default function Header() {
|
||||
{ href: `/${locale}/apoiar`, label: t("support"), highlight: true },
|
||||
];
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/60 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4">
|
||||
@@ -67,6 +95,66 @@ export default function Header() {
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Auth Section */}
|
||||
{!mounted ? (
|
||||
// Placeholder durante SSR para evitar mismatch
|
||||
<div className="w-20 h-8 animate-pulse bg-muted rounded" />
|
||||
) : isLoading ? (
|
||||
<div className="w-20 h-8 animate-pulse bg-muted rounded" />
|
||||
) : isAuthenticated && user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.username}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${locale}/perfil`} className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Perfil
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sair
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRegisterDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
Registrar
|
||||
</Button>
|
||||
<Button onClick={() => setLoginDialogOpen(true)} size="sm">
|
||||
Entrar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
@@ -74,6 +162,12 @@ export default function Header() {
|
||||
<MobileMenu links={links} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Dialog */}
|
||||
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
|
||||
|
||||
{/* Register Dialog */}
|
||||
<RegisterDialog open={registerDialogOpen} onOpenChange={setRegisterDialogOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
|
||||
const [emailOrUsername, setEmailOrUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
emailOrUsername,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Atualizar sessão
|
||||
await refreshSession();
|
||||
// Fechar diálogo
|
||||
onOpenChange(false);
|
||||
// Limpar formulário
|
||||
setEmailOrUsername("");
|
||||
setPassword("");
|
||||
// Recarregar página para atualizar estado
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError(data.error || "Erro ao fazer login");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Erro ao conectar com o servidor");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Entrar</DialogTitle>
|
||||
<DialogDescription>
|
||||
Faça login para acessar sua conta
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emailOrUsername">Email ou Username</Label>
|
||||
<Input
|
||||
id="emailOrUsername"
|
||||
type="text"
|
||||
placeholder="[email protected] ou username"
|
||||
value={emailOrUsername}
|
||||
onChange={(e) => setEmailOrUsername(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Entrando..." : "Entrar"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { useServers } from "@/lib/hooks/use-servers";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatPhone, formatInternationalPhone, unformatPhone, combinePhoneWithDialCode, getPhonePlaceholder, checkPasswordStrength, validatePasswordRequirements } from "@/lib/utils/formatters";
|
||||
import { COUNTRIES, DEFAULT_COUNTRY, type Country } from "@/lib/utils/countries";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { X, Plus, Check, XCircle, ChevronDown } from "lucide-react";
|
||||
|
||||
interface RegisterDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ServerSelection {
|
||||
serverName: string;
|
||||
characterName: string;
|
||||
isMain: boolean;
|
||||
}
|
||||
|
||||
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
const t = useTranslations("auth.register");
|
||||
const { refreshSession } = useSession();
|
||||
const { servers, isLoading: serversLoading, error: serversError } = useServers();
|
||||
|
||||
// Form fields
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [selectedCountry, setSelectedCountry] = useState<Country>(DEFAULT_COUNTRY);
|
||||
const [countrySearch, setCountrySearch] = useState("");
|
||||
|
||||
// Servers selection
|
||||
const [selectedServers, setSelectedServers] = useState<ServerSelection[]>([]);
|
||||
const [serverSearch, setServerSearch] = useState("");
|
||||
const [showServerDropdown, setShowServerDropdown] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [passwordStrength, setPasswordStrength] = useState<"weak" | "medium" | "strong">("weak");
|
||||
|
||||
// Filtered servers based on search
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!serverSearch.trim()) return servers;
|
||||
const searchLower = serverSearch.toLowerCase();
|
||||
return servers.filter(
|
||||
(s) =>
|
||||
s.serverName.toLowerCase().includes(searchLower) &&
|
||||
!selectedServers.some((sel) => sel.serverName === s.serverName)
|
||||
);
|
||||
}, [serverSearch, servers, selectedServers]);
|
||||
|
||||
// Update password strength
|
||||
useEffect(() => {
|
||||
if (password) {
|
||||
setPasswordStrength(checkPasswordStrength(password));
|
||||
} else {
|
||||
setPasswordStrength("weak");
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setFirstName("");
|
||||
setLastName("");
|
||||
setPhone("");
|
||||
setSelectedCountry(DEFAULT_COUNTRY);
|
||||
setCountrySearch("");
|
||||
setSelectedServers([]);
|
||||
setServerSearch("");
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
setPasswordStrength("weak");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const addServer = (serverName: string) => {
|
||||
if (selectedServers.some((s) => s.serverName === serverName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirst = selectedServers.length === 0;
|
||||
setSelectedServers([
|
||||
...selectedServers,
|
||||
{
|
||||
serverName,
|
||||
characterName: "",
|
||||
isMain: isFirst,
|
||||
},
|
||||
]);
|
||||
setServerSearch("");
|
||||
setShowServerDropdown(false);
|
||||
};
|
||||
|
||||
const removeServer = (index: number) => {
|
||||
const newServers = selectedServers.filter((_, i) => i !== index);
|
||||
// If we removed the main character and there are still servers, make first one main
|
||||
if (selectedServers[index].isMain && newServers.length > 0) {
|
||||
newServers[0].isMain = true;
|
||||
}
|
||||
setSelectedServers(newServers);
|
||||
};
|
||||
|
||||
const updateServerCharacter = (index: number, characterName: string) => {
|
||||
const newServers = [...selectedServers];
|
||||
newServers[index].characterName = characterName;
|
||||
setSelectedServers(newServers);
|
||||
};
|
||||
|
||||
const setMainServer = (index: number) => {
|
||||
const newServers = selectedServers.map((s, i) => ({
|
||||
...s,
|
||||
isMain: i === index,
|
||||
}));
|
||||
setSelectedServers(newServers);
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value: string) => {
|
||||
const formatted = formatInternationalPhone(value, selectedCountry.dialCode, selectedCountry.code);
|
||||
setPhone(formatted);
|
||||
};
|
||||
|
||||
// Filtrar países para busca
|
||||
const filteredCountries = useMemo(() => {
|
||||
if (!countrySearch.trim()) return COUNTRIES;
|
||||
const searchLower = countrySearch.toLowerCase();
|
||||
return COUNTRIES.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchLower) ||
|
||||
c.dialCode.includes(searchLower) ||
|
||||
c.code.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [countrySearch]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!username.trim()) {
|
||||
errors.username = "Username é obrigatório";
|
||||
} else if (username.length < 3 || username.length > 50) {
|
||||
errors.username = "Username deve ter entre 3 e 50 caracteres";
|
||||
}
|
||||
|
||||
if (!email.trim()) {
|
||||
errors.email = "Email é obrigatório";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = "Email inválido";
|
||||
}
|
||||
|
||||
const passwordValidation = validatePasswordRequirements(password);
|
||||
if (!password) {
|
||||
errors.password = "Senha é obrigatória";
|
||||
} else if (!passwordValidation.isValid) {
|
||||
errors.password = passwordValidation.errors[0];
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errors.confirmPassword = t("errors.passwordMismatch");
|
||||
}
|
||||
|
||||
if (!firstName.trim()) {
|
||||
errors.firstName = "Nome é obrigatório";
|
||||
}
|
||||
|
||||
if (!lastName.trim()) {
|
||||
errors.lastName = "Sobrenome é obrigatório";
|
||||
}
|
||||
|
||||
if (selectedServers.length === 0) {
|
||||
errors.servers = t("errors.noServers");
|
||||
}
|
||||
|
||||
selectedServers.forEach((server, index) => {
|
||||
if (!server.characterName.trim()) {
|
||||
errors[`server_${index}`] = t("errors.characterNameRequired");
|
||||
}
|
||||
});
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
username: username.trim().toLowerCase(),
|
||||
email: email.trim().toLowerCase(),
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone ? combinePhoneWithDialCode(selectedCountry.dialCode, phone) : undefined,
|
||||
servers: selectedServers.map((s) => ({
|
||||
serverName: s.serverName,
|
||||
characterName: s.characterName.trim(),
|
||||
isMain: s.isMain,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
// Verificar se a resposta é JSON antes de fazer parse
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
console.error("Resposta não é JSON do registro:", text.substring(0, 200));
|
||||
setError("Erro: resposta inválida do servidor");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
await refreshSession();
|
||||
onOpenChange(false);
|
||||
window.location.reload();
|
||||
} else {
|
||||
if (data.details) {
|
||||
// Field-specific errors
|
||||
const detailsErrors: Record<string, string> = {};
|
||||
data.details.forEach((detail: { field: string; message: string }) => {
|
||||
detailsErrors[detail.field] = detail.message;
|
||||
});
|
||||
setFieldErrors(detailsErrors);
|
||||
} else {
|
||||
setError(data.error || "Erro ao criar conta");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Erro ao conectar com o servidor");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPasswordStrengthColor = () => {
|
||||
switch (passwordStrength) {
|
||||
case "weak":
|
||||
return "bg-red-500";
|
||||
case "medium":
|
||||
return "bg-yellow-500";
|
||||
case "strong":
|
||||
return "bg-green-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Credentials Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Credenciais</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.username ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.username && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.email ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.password ? "border-destructive" : ""}
|
||||
/>
|
||||
{password && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getPasswordStrengthColor()} transition-all`}
|
||||
style={{
|
||||
width: passwordStrength === "weak" ? "33%" : passwordStrength === "medium" ? "66%" : "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`passwordStrength.${passwordStrength}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fieldErrors.password && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">{t("confirmPassword")}</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder={t("confirmPasswordPlaceholder")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.confirmPassword ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personal Info Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Dados Pessoais</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">{t("firstName")}</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
placeholder={t("firstNamePlaceholder")}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.firstName ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.firstName && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">{t("lastName")}</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
placeholder={t("lastNamePlaceholder")}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.lastName ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.lastName && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">{t("phone")}</Label>
|
||||
<div className="flex gap-2">
|
||||
{/* Seletor de País */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-[140px] justify-between shrink-0"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{selectedCountry.flag}</span>
|
||||
<span className="text-sm">{selectedCountry.dialCode}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[300px] max-h-[300px] overflow-y-auto">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("searchCountry") || "Buscar país..."}
|
||||
value={countrySearch}
|
||||
onChange={(e) => setCountrySearch(e.target.value)}
|
||||
className="mb-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{filteredCountries.map((country) => (
|
||||
<DropdownMenuItem
|
||||
key={country.code}
|
||||
onClick={() => {
|
||||
setSelectedCountry(country);
|
||||
setCountrySearch("");
|
||||
// Reformatar telefone com novo país
|
||||
if (phone) {
|
||||
const unformatted = unformatPhone(phone);
|
||||
const reformatted = formatInternationalPhone(
|
||||
unformatted,
|
||||
country.dialCode,
|
||||
country.code
|
||||
);
|
||||
setPhone(reformatted);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-2 w-full">
|
||||
<span>{country.flag}</span>
|
||||
<span className="flex-1">{country.name}</span>
|
||||
<span className="text-muted-foreground text-sm">{country.dialCode}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Campo de Telefone */}
|
||||
<Input
|
||||
id="phone"
|
||||
type="text"
|
||||
placeholder={getPhonePlaceholder(selectedCountry.code)}
|
||||
value={phone}
|
||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||
disabled={isLoading}
|
||||
maxLength={20}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("phoneHint")}</p>
|
||||
{fieldErrors.phone && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servers Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("servers")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("serversDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Search/Add */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("selectServer")}
|
||||
value={serverSearch}
|
||||
onChange={(e) => {
|
||||
setServerSearch(e.target.value);
|
||||
setShowServerDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowServerDropdown(true)}
|
||||
disabled={isLoading || serversLoading}
|
||||
/>
|
||||
{showServerDropdown && filteredServers.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||
{filteredServers.slice(0, 10).map((server) => (
|
||||
<button
|
||||
key={server.serverId}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent rounded-sm text-sm"
|
||||
onClick={() => addServer(server.serverName)}
|
||||
>
|
||||
{server.serverName}
|
||||
{server.serverType && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({server.serverType})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Servers */}
|
||||
{selectedServers.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{selectedServers.map((server, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 border rounded-md space-y-2 bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{server.serverName}</Badge>
|
||||
{server.isMain && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
{t("mainCharacter")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!server.isMain && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setMainServer(index)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("mainCharacter")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeServer(index)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`character-${index}`}>{t("characterName")}</Label>
|
||||
<Input
|
||||
id={`character-${index}`}
|
||||
type="text"
|
||||
placeholder={t("characterNamePlaceholder")}
|
||||
value={server.characterName}
|
||||
onChange={(e) => updateServerCharacter(index, e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors[`server_${index}`] ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors[`server_${index}`] && (
|
||||
<p className="text-sm text-destructive">
|
||||
{fieldErrors[`server_${index}`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldErrors.servers && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.servers}</p>
|
||||
)}
|
||||
{serversError && (
|
||||
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
Erro ao carregar servidores: {serversError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || serversLoading}>
|
||||
{isLoading ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +15,22 @@ import { useTranslations } from "next-intl";
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const t = useTranslations("header.theme");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Evitar hidratação mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
// Renderizar um placeholder durante SSR para manter layout consistente
|
||||
return (
|
||||
<Button variant="outline" size="icon" disabled>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Módulo de geração e validação de tokens JWT
|
||||
* Access tokens (15min) e Refresh tokens (7 dias)
|
||||
*/
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
interface TokenPayload {
|
||||
userId: number;
|
||||
username: string;
|
||||
type: "access" | "refresh";
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém o secret do JWT (lazy loading)
|
||||
*/
|
||||
function getJWTSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET não está definido nas variáveis de ambiente");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém o refresh secret do JWT (lazy loading)
|
||||
*/
|
||||
function getJWTRefreshSecret(): string {
|
||||
const secret = process.env.JWT_REFRESH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"JWT_REFRESH_SECRET não está definido nas variáveis de ambiente"
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
// Tempos de expiração
|
||||
const ACCESS_TOKEN_EXPIRY = "15m"; // 15 minutos
|
||||
const REFRESH_TOKEN_EXPIRY = "7d"; // 7 dias
|
||||
|
||||
/**
|
||||
* Gera access token JWT
|
||||
* @param userId - ID do usuário
|
||||
* @param username - Nome de usuário
|
||||
* @returns Access token
|
||||
*/
|
||||
export function generateAccessToken(
|
||||
userId: number,
|
||||
username: string
|
||||
): string {
|
||||
const payload: TokenPayload = {
|
||||
userId,
|
||||
username,
|
||||
type: "access",
|
||||
};
|
||||
|
||||
return jwt.sign(payload, getJWTSecret(), {
|
||||
expiresIn: ACCESS_TOKEN_EXPIRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera refresh token JWT
|
||||
* @param userId - ID do usuário
|
||||
* @param username - Nome de usuário
|
||||
* @returns Refresh token
|
||||
*/
|
||||
export function generateRefreshToken(
|
||||
userId: number,
|
||||
username: string
|
||||
): string {
|
||||
const payload: TokenPayload = {
|
||||
userId,
|
||||
username,
|
||||
type: "refresh",
|
||||
};
|
||||
|
||||
return jwt.sign(payload, getJWTRefreshSecret(), {
|
||||
expiresIn: REFRESH_TOKEN_EXPIRY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida e decodifica access token
|
||||
* @param token - Access token
|
||||
* @returns Payload do token ou null se inválido
|
||||
*/
|
||||
export function verifyAccessToken(token: string): {
|
||||
userId: number;
|
||||
username: string;
|
||||
} | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, getJWTSecret()) as TokenPayload;
|
||||
|
||||
if (decoded.type !== "access") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida e decodifica refresh token
|
||||
* @param token - Refresh token
|
||||
* @returns Payload do token ou null se inválido
|
||||
*/
|
||||
export function verifyRefreshToken(token: string): {
|
||||
userId: number;
|
||||
username: string;
|
||||
} | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, getJWTRefreshSecret()) as TokenPayload;
|
||||
|
||||
if (decoded.type !== "refresh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Middleware de autenticação
|
||||
* Extrai e valida tokens JWT das requisições
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { verifyAccessToken } from "./jwt";
|
||||
import { getSession } from "@/lib/redis/session";
|
||||
|
||||
/**
|
||||
* Extrai token do header Authorization
|
||||
* @param request - Request do Next.js
|
||||
* @returns Token ou null
|
||||
*/
|
||||
function extractToken(request: NextRequest): string | null {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return authHeader.substring(7); // Remove "Bearer "
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware que requer autenticação
|
||||
* Retorna dados do usuário ou NextResponse com erro
|
||||
* Suporta tanto Bearer token quanto cookie sessionId
|
||||
* @param request - Request do Next.js
|
||||
* @returns Dados do usuário autenticado ou NextResponse com erro
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: NextRequest
|
||||
): Promise<
|
||||
| { userId: number; username: string }
|
||||
| NextResponse<{ success: false; error: string }>
|
||||
> {
|
||||
// Tentar primeiro com Bearer token
|
||||
const token = extractToken(request);
|
||||
|
||||
if (token) {
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (payload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
// Se não tiver token no header, tentar com cookie
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("sessionId")?.value;
|
||||
|
||||
if (sessionId) {
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (session) {
|
||||
const tokenPayload = verifyAccessToken(session.accessToken);
|
||||
|
||||
if (tokenPayload) {
|
||||
return tokenPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Se der erro ao acessar cookies, continuar com o erro de token
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Token de autenticação não fornecido ou inválido",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware opcional de autenticação
|
||||
* Retorna dados do usuário se autenticado, null caso contrário
|
||||
* Não lança erro se não autenticado
|
||||
* @param request - Request do Next.js
|
||||
* @returns Dados do usuário ou null
|
||||
*/
|
||||
export async function optionalAuth(
|
||||
request: NextRequest
|
||||
): Promise<{ userId: number; username: string } | null> {
|
||||
// Tentar primeiro com Bearer token
|
||||
const token = extractToken(request);
|
||||
|
||||
if (token) {
|
||||
const payload = verifyAccessToken(token);
|
||||
if (payload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
// Tentar com cookie
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("sessionId")?.value;
|
||||
|
||||
if (sessionId) {
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (session) {
|
||||
const tokenPayload = verifyAccessToken(session.accessToken);
|
||||
return tokenPayload;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorar erros de cookie
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Módulo de hash e verificação de senhas
|
||||
* Usa bcrypt para hashing seguro
|
||||
*/
|
||||
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
/**
|
||||
* Gera hash de senha usando bcrypt
|
||||
* @param password - Senha em texto plano
|
||||
* @returns Hash da senha
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
return hash;
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao gerar hash de senha:", error);
|
||||
throw new Error("Falha ao processar senha");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compara senha em texto plano com hash
|
||||
* @param password - Senha em texto plano
|
||||
* @param hash - Hash armazenado
|
||||
* @returns true se a senha corresponde, false caso contrário
|
||||
*/
|
||||
export async function comparePassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const match = await bcrypt.compare(password, hash);
|
||||
return match;
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao comparar senha:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Módulo de conexão com PostgreSQL
|
||||
* Gerencia pool de conexões e fornece helpers para queries
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from "pg";
|
||||
|
||||
let pool: Pool | null = null;
|
||||
|
||||
/**
|
||||
* Valida variáveis de ambiente necessárias
|
||||
*/
|
||||
function validateEnvVars() {
|
||||
if (!process.env.PG_DB) {
|
||||
throw new Error(
|
||||
"PG_DB não está definido. Configure a variável de ambiente PG_DB com a connection string do PostgreSQL."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém ou cria o pool de conexões PostgreSQL
|
||||
*/
|
||||
export function getPool(): Pool {
|
||||
if (!pool) {
|
||||
validateEnvVars();
|
||||
|
||||
const connectionString = process.env.PG_DB;
|
||||
|
||||
pool = new Pool({
|
||||
connectionString,
|
||||
min: 2, // Mínimo de conexões no pool
|
||||
max: 10, // Máximo de conexões no pool
|
||||
idleTimeoutMillis: 30000, // Fecha conexões idle após 30s
|
||||
connectionTimeoutMillis: 5000, // Timeout ao tentar conectar
|
||||
});
|
||||
|
||||
// Event listeners para debug
|
||||
pool.on("error", (err) => {
|
||||
console.error("❌ Erro inesperado no pool de conexões PostgreSQL:", err);
|
||||
});
|
||||
|
||||
pool.on("connect", () => {
|
||||
console.log("✅ Nova conexão estabelecida com PostgreSQL");
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa uma query SQL com parâmetros
|
||||
* @param text - SQL query com placeholders ($1, $2, etc)
|
||||
* @param params - Array de parâmetros para a query
|
||||
* @returns Resultado da query
|
||||
*/
|
||||
export async function query<T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>> {
|
||||
const poolInstance = getPool();
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await poolInstance.query<T>(text, params);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Log apenas em desenvolvimento ou queries lentas (>100ms)
|
||||
if (process.env.NODE_ENV !== "production" || duration > 100) {
|
||||
console.log("📊 Query executada", {
|
||||
duration: `${duration}ms`,
|
||||
query: text.substring(0, 100),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao executar query:", {
|
||||
query: text.substring(0, 100),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check do banco de dados
|
||||
* @returns true se o banco está acessível, false caso contrário
|
||||
*/
|
||||
export async function healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const poolInstance = getPool();
|
||||
const result = await poolInstance.query("SELECT NOW()");
|
||||
return result.rows.length > 0;
|
||||
} catch (error) {
|
||||
console.error("❌ Health check falhou:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fecha o pool de conexões
|
||||
* Útil para cleanup em testes ou shutdown graceful
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
console.log("🔌 Pool de conexões PostgreSQL fechado");
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup ao encerrar o processo
|
||||
if (typeof process !== "undefined") {
|
||||
process.on("SIGINT", async () => {
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Migration 001: Tabelas de usuários e personagens
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de usuários
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
cpf VARCHAR(14) UNIQUE NOT NULL,
|
||||
account_level VARCHAR(20) DEFAULT 'free' CHECK (account_level IN ('free', 'bronze', 'silver', 'gold')),
|
||||
has_donated BOOLEAN DEFAULT FALSE,
|
||||
whatsapp_notifications BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_verified BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Tabela de personagens dos usuários
|
||||
CREATE TABLE IF NOT EXISTS user_characters (
|
||||
character_id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
character_name VARCHAR(100) NOT NULL,
|
||||
server_name VARCHAR(50) NOT NULL,
|
||||
is_main BOOLEAN DEFAULT FALSE,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(character_name, server_name)
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_user_characters_user_id ON user_characters(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_cpf ON users(cpf);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Migration 002: Tabela de servidores do Tibia
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tornar CPF opcional na tabela users (para compatibilidade)
|
||||
ALTER TABLE users ALTER COLUMN cpf DROP NOT NULL;
|
||||
|
||||
-- Tabela de servidores
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
server_id SERIAL PRIMARY KEY,
|
||||
server_name VARCHAR(50) UNIQUE NOT NULL,
|
||||
server_type VARCHAR(20) CHECK (server_type IN ('pvp', 'no-pvp', 'retro-pvp', 'hardcore-pvp')),
|
||||
location VARCHAR(10) CHECK (location IN ('NA', 'SA', 'EU', 'OC')),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Estatísticas de tracking
|
||||
total_listings INTEGER DEFAULT 0,
|
||||
total_users INTEGER DEFAULT 0,
|
||||
total_subscribers INTEGER DEFAULT 0,
|
||||
total_transactions INTEGER DEFAULT 0,
|
||||
total_volume DECIMAL(15, 2) DEFAULT 0,
|
||||
average_listing_price DECIMAL(10, 2) DEFAULT 0,
|
||||
last_activity_at TIMESTAMP,
|
||||
|
||||
-- Metadados
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_name ON servers(server_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_active ON servers(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_type ON servers(server_type);
|
||||
|
||||
-- Trigger para atualizar updated_at automaticamente
|
||||
CREATE OR REPLACE FUNCTION update_servers_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_servers_updated_at
|
||||
BEFORE UPDATE ON servers
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_servers_updated_at();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration 003: Corrigir constraint de location para incluir BR
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Remover constraint antigo
|
||||
ALTER TABLE servers DROP CONSTRAINT IF EXISTS servers_location_check;
|
||||
|
||||
-- Adicionar novo constraint que inclui BR
|
||||
ALTER TABLE servers ADD CONSTRAINT servers_location_check
|
||||
CHECK (location IN ('NA', 'SA', 'EU', 'OC', 'BR'));
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Migration 004: Tabela de anúncios (listings) do marketplace
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de anúncios
|
||||
CREATE TABLE IF NOT EXISTS listings (
|
||||
listing_id SERIAL PRIMARY KEY,
|
||||
seller_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL, -- referência ao banco de itens do TibiaWiki
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
quantity INTEGER DEFAULT 1 CHECK (quantity > 0),
|
||||
server_name VARCHAR(50) NOT NULL,
|
||||
|
||||
-- Preços aceitos
|
||||
price_pix DECIMAL(10, 2),
|
||||
price_tibia_coins INTEGER,
|
||||
price_gold_coins BIGINT,
|
||||
|
||||
-- Leilão
|
||||
is_auction BOOLEAN DEFAULT FALSE,
|
||||
current_bid_amount DECIMAL(10, 2),
|
||||
current_bidder_id INTEGER REFERENCES users(user_id),
|
||||
auction_ends_at TIMESTAMP,
|
||||
|
||||
-- Metadados
|
||||
description TEXT,
|
||||
listing_status VARCHAR(20) DEFAULT 'active' CHECK (listing_status IN ('active', 'sold', 'cancelled', 'in_escrow', 'expired')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
sold_at TIMESTAMP,
|
||||
views_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings(listing_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_item_id ON listings(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_seller_id ON listings(seller_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_server ON listings(server_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_created_at ON listings(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_listings_auction ON listings(is_auction, auction_ends_at) WHERE is_auction = TRUE;
|
||||
|
||||
-- Trigger para atualizar updated_at automaticamente
|
||||
CREATE OR REPLACE FUNCTION update_listings_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_listings_updated_at
|
||||
BEFORE UPDATE ON listings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_listings_updated_at();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration 005: Tabela de lances em leilões
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de lances
|
||||
CREATE TABLE IF NOT EXISTS bids (
|
||||
bid_id SERIAL PRIMARY KEY,
|
||||
listing_id INTEGER NOT NULL REFERENCES listings(listing_id) ON DELETE CASCADE,
|
||||
bidder_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
bid_amount DECIMAL(10, 2) NOT NULL,
|
||||
bid_type VARCHAR(20) CHECK (bid_type IN ('pix', 'tibia_coins', 'gold_coins')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_listing ON bids(listing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_bidder ON bids(bidder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_active ON bids(is_active) WHERE is_active = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_bids_amount ON bids(listing_id, bid_amount DESC) WHERE is_active = TRUE;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Migration 006: Tabela de transações do sistema de escrow
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de transações
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
transaction_id SERIAL PRIMARY KEY,
|
||||
listing_id INTEGER REFERENCES listings(listing_id),
|
||||
buyer_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
seller_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
|
||||
-- Detalhes do item
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
server_name VARCHAR(50) NOT NULL,
|
||||
|
||||
-- Valores
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
payment_method VARCHAR(20) CHECK (payment_method IN ('pix', 'tibia_coins', 'gold_coins')),
|
||||
escrow_fee DECIMAL(10, 2), -- 2% da transação
|
||||
|
||||
-- Status da transação
|
||||
transaction_status VARCHAR(30) DEFAULT 'pending' CHECK (transaction_status IN (
|
||||
'pending', -- aguardando depósito
|
||||
'deposited', -- comprador depositou
|
||||
'item_delivered', -- vendedor entregou
|
||||
'completed', -- pagamento liberado
|
||||
'disputed', -- em disputa
|
||||
'cancelled', -- cancelada
|
||||
'refunded' -- reembolsada
|
||||
)),
|
||||
|
||||
-- Datas importantes
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deposited_at TIMESTAMP,
|
||||
delivered_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
|
||||
-- Confirmações
|
||||
buyer_confirmed BOOLEAN DEFAULT FALSE,
|
||||
seller_confirmed BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Notas
|
||||
buyer_notes TEXT,
|
||||
seller_notes TEXT
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_buyer ON transactions(buyer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_seller ON transactions(seller_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(transaction_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_listing ON transactions(listing_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at DESC);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration 007: Tabela de depósitos em escrow
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de depósitos em escrow
|
||||
CREATE TABLE IF NOT EXISTS escrow_deposits (
|
||||
deposit_id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(transaction_id) ON DELETE CASCADE,
|
||||
depositor_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
payment_method VARCHAR(20),
|
||||
payment_proof_url TEXT,
|
||||
deposit_status VARCHAR(20) DEFAULT 'pending' CHECK (deposit_status IN ('pending', 'confirmed', 'released', 'refunded')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
confirmed_at TIMESTAMP,
|
||||
released_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_escrow_deposits_transaction ON escrow_deposits(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_escrow_deposits_depositor ON escrow_deposits(depositor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_escrow_deposits_status ON escrow_deposits(deposit_status);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Migration 008: Tabela de disputas
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de disputas
|
||||
CREATE TABLE IF NOT EXISTS disputes (
|
||||
dispute_id SERIAL PRIMARY KEY,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(transaction_id) ON DELETE CASCADE,
|
||||
raised_by_user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
dispute_reason TEXT NOT NULL,
|
||||
dispute_status VARCHAR(20) DEFAULT 'open' CHECK (dispute_status IN ('open', 'investigating', 'resolved', 'closed')),
|
||||
resolution_notes TEXT,
|
||||
resolved_by_admin_id INTEGER REFERENCES users(user_id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_disputes_transaction ON disputes(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_disputes_raised_by ON disputes(raised_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes(dispute_status);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Migration 009: Tabela de histórico de preços de itens
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de histórico de preços
|
||||
CREATE TABLE IF NOT EXISTS item_price_history (
|
||||
price_history_id SERIAL PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL,
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
server_name VARCHAR(50),
|
||||
price_amount DECIMAL(10, 2) NOT NULL,
|
||||
price_type VARCHAR(20) CHECK (price_type IN ('pix', 'tibia_coins', 'gold_coins')),
|
||||
transaction_id INTEGER REFERENCES transactions(transaction_id),
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_item ON item_price_history(item_id, recorded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_server ON item_price_history(server_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_transaction ON item_price_history(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_recorded_at ON item_price_history(recorded_at DESC);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration 010: Tabela de notificações
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de notificações
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
notification_id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
notification_type VARCHAR(30) CHECK (notification_type IN ('bid', 'sale', 'purchase', 'escrow', 'dispute', 'system')),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
related_id INTEGER, -- ID da transação/listing relacionado
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
send_whatsapp BOOLEAN DEFAULT FALSE,
|
||||
whatsapp_sent BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications(notification_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, is_read) WHERE is_read = FALSE;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migration 011: Tabela de doações
|
||||
-- Criado em: 2025-01-27
|
||||
|
||||
-- Tabela de doações
|
||||
CREATE TABLE IF NOT EXISTS donations (
|
||||
donation_id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(user_id),
|
||||
donor_name VARCHAR(100),
|
||||
donor_email VARCHAR(255),
|
||||
amount DECIMAL(10, 2) NOT NULL,
|
||||
donation_level VARCHAR(20) CHECK (donation_level IN ('coffee', 'snack', 'lunch', 'custom')),
|
||||
payment_method VARCHAR(20) DEFAULT 'pix',
|
||||
payment_status VARCHAR(20) DEFAULT 'pending' CHECK (payment_status IN ('pending', 'completed', 'failed', 'refunded')),
|
||||
abacatepay_id VARCHAR(100),
|
||||
message TEXT,
|
||||
is_anonymous BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_donations_user ON donations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_donations_status ON donations(payment_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_donations_abacatepay_id ON donations(abacatepay_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_donations_created_at ON donations(created_at DESC);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Script de execução de migrations
|
||||
* Executa todas as migrations SQL em ordem
|
||||
*/
|
||||
|
||||
// Carregar variáveis de ambiente
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Tentar carregar .env.local primeiro, depois .env
|
||||
config({ path: resolve(process.cwd(), ".env.local") });
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { getPool, query, healthCheck } from "../index";
|
||||
|
||||
const MIGRATIONS_DIR = join(__dirname, ".");
|
||||
|
||||
// Lista de migrations em ordem
|
||||
const MIGRATIONS = [
|
||||
"001_users.sql",
|
||||
"002_servers.sql",
|
||||
"003_fix_servers_location.sql",
|
||||
"004_listings.sql",
|
||||
"005_bids.sql",
|
||||
"006_transactions.sql",
|
||||
"007_escrow_deposits.sql",
|
||||
"008_disputes.sql",
|
||||
"009_item_price_history.sql",
|
||||
"010_notifications.sql",
|
||||
"011_donations.sql",
|
||||
];
|
||||
|
||||
async function runMigrations() {
|
||||
console.log("🚀 Iniciando execução de migrations...\n");
|
||||
|
||||
// Verificar conexão com banco
|
||||
console.log("🔍 Verificando conexão com PostgreSQL...");
|
||||
const isHealthy = await healthCheck();
|
||||
|
||||
if (!isHealthy) {
|
||||
console.error("❌ Falha na conexão com PostgreSQL. Verifique a variável PG_DB.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("✅ Conexão estabelecida com sucesso\n");
|
||||
|
||||
// Verificar tabela de controle de migrations (opcional)
|
||||
try {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(255) PRIMARY KEY,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao criar tabela de controle:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Executar cada migration
|
||||
for (const migrationFile of MIGRATIONS) {
|
||||
const migrationPath = join(MIGRATIONS_DIR, migrationFile);
|
||||
const version = migrationFile.replace(".sql", "");
|
||||
|
||||
try {
|
||||
// Verificar se já foi executada
|
||||
const checkResult = await query<{ version: string }>(
|
||||
"SELECT version FROM schema_migrations WHERE version = $1",
|
||||
[version]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length > 0) {
|
||||
console.log(`⏭️ Migration ${version} já executada, pulando...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📝 Executando migration: ${migrationFile}...`);
|
||||
|
||||
// Ler arquivo SQL
|
||||
const sql = readFileSync(migrationPath, "utf-8");
|
||||
|
||||
// Executar migration
|
||||
await query(sql);
|
||||
|
||||
// Registrar como executada
|
||||
await query(
|
||||
"INSERT INTO schema_migrations (version) VALUES ($1)",
|
||||
[version]
|
||||
);
|
||||
|
||||
console.log(`✅ Migration ${version} executada com sucesso\n`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Erro ao executar migration ${version}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🎉 Todas as migrations foram executadas com sucesso!");
|
||||
|
||||
// Fechar pool
|
||||
const pool = getPool();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Executar
|
||||
runMigrations().catch((error) => {
|
||||
console.error("❌ Erro fatal:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Queries de bids (lances em leilões)
|
||||
* Operações para a tabela bids
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface Bid {
|
||||
bid_id: number;
|
||||
listing_id: number;
|
||||
bidder_id: number;
|
||||
bid_amount: number;
|
||||
bid_type: "pix" | "tibia_coins" | "gold_coins" | null;
|
||||
created_at: Date;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateBidData {
|
||||
listing_id: number;
|
||||
bidder_id: number;
|
||||
bid_amount: number;
|
||||
bid_type?: "pix" | "tibia_coins" | "gold_coins";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo lance
|
||||
*/
|
||||
export async function createBid(data: CreateBidData): Promise<Bid> {
|
||||
const result = await query<Bid>(
|
||||
`
|
||||
INSERT INTO bids (listing_id, bidder_id, bid_amount, bid_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`,
|
||||
[data.listing_id, data.bidder_id, data.bid_amount, data.bid_type || null]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca lances de um anúncio
|
||||
*/
|
||||
export async function getBidsByListing(
|
||||
listingId: number,
|
||||
activeOnly: boolean = true
|
||||
): Promise<Bid[]> {
|
||||
let sql = `
|
||||
SELECT * FROM bids
|
||||
WHERE listing_id = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [listingId];
|
||||
|
||||
if (activeOnly) {
|
||||
sql += ` AND is_active = TRUE`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY bid_amount DESC, created_at DESC`;
|
||||
|
||||
const result = await query<Bid>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca o maior lance de um anúncio
|
||||
*/
|
||||
export async function getHighestBid(listingId: number): Promise<Bid | null> {
|
||||
const result = await query<Bid>(
|
||||
`
|
||||
SELECT * FROM bids
|
||||
WHERE listing_id = $1 AND is_active = TRUE
|
||||
ORDER BY bid_amount DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela um lance (desativa)
|
||||
*/
|
||||
export async function cancelBid(bidId: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE bids
|
||||
SET is_active = FALSE
|
||||
WHERE bid_id = $1
|
||||
`,
|
||||
[bidId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca lance por ID
|
||||
*/
|
||||
export async function getBidById(bidId: number): Promise<Bid | null> {
|
||||
const result = await query<Bid>(
|
||||
`
|
||||
SELECT * FROM bids WHERE bid_id = $1
|
||||
`,
|
||||
[bidId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Queries de personagens
|
||||
* CRUD completo para a tabela user_characters
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface Character {
|
||||
character_id: number;
|
||||
user_id: number;
|
||||
character_name: string;
|
||||
server_name: string;
|
||||
is_main: boolean;
|
||||
verified: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateCharacterData {
|
||||
character_name: string;
|
||||
server_name: string;
|
||||
is_main?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCharacterData {
|
||||
character_name?: string;
|
||||
server_name?: string;
|
||||
is_main?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo personagem
|
||||
* Se isMain=true, desmarca outros personagens como main
|
||||
*/
|
||||
export async function createCharacter(
|
||||
userId: number,
|
||||
data: CreateCharacterData
|
||||
): Promise<Character> {
|
||||
// Usar transação para garantir atomicidade
|
||||
const { getPool } = await import("../index");
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Se este personagem será main, desmarcar outros
|
||||
if (data.is_main) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE user_characters
|
||||
SET is_main = FALSE
|
||||
WHERE user_id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Inserir novo personagem
|
||||
const result = await client.query<Character>(
|
||||
`
|
||||
INSERT INTO user_characters (user_id, character_name, server_name, is_main)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`,
|
||||
[userId, data.character_name, data.server_name, data.is_main || false]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os personagens de um usuário
|
||||
*/
|
||||
export async function getUserCharacters(userId: number): Promise<Character[]> {
|
||||
const result = await query<Character>(
|
||||
`
|
||||
SELECT * FROM user_characters
|
||||
WHERE user_id = $1
|
||||
ORDER BY is_main DESC, created_at ASC
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca personagem por ID
|
||||
*/
|
||||
export async function getCharacterById(
|
||||
characterId: number
|
||||
): Promise<Character | null> {
|
||||
const result = await query<Character>(
|
||||
`
|
||||
SELECT * FROM user_characters WHERE character_id = $1
|
||||
`,
|
||||
[characterId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza personagem
|
||||
* Verifica ownership e se isMain=true, desmarca outros
|
||||
*/
|
||||
export async function updateCharacter(
|
||||
characterId: number,
|
||||
userId: number,
|
||||
data: UpdateCharacterData
|
||||
): Promise<Character> {
|
||||
// Verificar ownership primeiro
|
||||
const existing = await getCharacterById(characterId);
|
||||
if (!existing) {
|
||||
throw new Error("Personagem não encontrado");
|
||||
}
|
||||
|
||||
if (existing.user_id !== userId) {
|
||||
throw new Error("Você não tem permissão para atualizar este personagem");
|
||||
}
|
||||
|
||||
// Usar transação para garantir atomicidade
|
||||
const { getPool } = await import("../index");
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Se este personagem será main, desmarcar outros
|
||||
if (data.is_main) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE user_characters
|
||||
SET is_main = FALSE
|
||||
WHERE user_id = $1 AND character_id != $2
|
||||
`,
|
||||
[userId, characterId]
|
||||
);
|
||||
}
|
||||
|
||||
// Construir query de update
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.character_name !== undefined) {
|
||||
updates.push(`character_name = $${paramIndex++}`);
|
||||
values.push(data.character_name);
|
||||
}
|
||||
|
||||
if (data.server_name !== undefined) {
|
||||
updates.push(`server_name = $${paramIndex++}`);
|
||||
values.push(data.server_name);
|
||||
}
|
||||
|
||||
if (data.is_main !== undefined) {
|
||||
updates.push(`is_main = $${paramIndex++}`);
|
||||
values.push(data.is_main);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// Se não há updates, retorna personagem atual
|
||||
await client.query("COMMIT");
|
||||
return existing;
|
||||
}
|
||||
|
||||
values.push(characterId);
|
||||
|
||||
const result = await client.query<Character>(
|
||||
`
|
||||
UPDATE user_characters
|
||||
SET ${updates.join(", ")}
|
||||
WHERE character_id = $${paramIndex}
|
||||
RETURNING *
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove personagem
|
||||
* Verifica ownership antes de remover
|
||||
*/
|
||||
export async function deleteCharacter(
|
||||
characterId: number,
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
// Verificar ownership
|
||||
const existing = await getCharacterById(characterId);
|
||||
if (!existing) {
|
||||
throw new Error("Personagem não encontrado");
|
||||
}
|
||||
|
||||
if (existing.user_id !== userId) {
|
||||
throw new Error("Você não tem permissão para remover este personagem");
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
DELETE FROM user_characters WHERE character_id = $1
|
||||
`,
|
||||
[characterId]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Queries de listings (anúncios)
|
||||
* CRUD completo para a tabela listings
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface Listing {
|
||||
listing_id: number;
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
price_pix: number | null;
|
||||
price_tibia_coins: number | null;
|
||||
price_gold_coins: number | null;
|
||||
is_auction: boolean;
|
||||
current_bid_amount: number | null;
|
||||
current_bidder_id: number | null;
|
||||
auction_ends_at: Date | null;
|
||||
description: string | null;
|
||||
listing_status: "active" | "sold" | "cancelled" | "in_escrow" | "expired";
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
sold_at: Date | null;
|
||||
views_count: number;
|
||||
}
|
||||
|
||||
export interface CreateListingData {
|
||||
seller_id: number;
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
price_pix?: number;
|
||||
price_tibia_coins?: number;
|
||||
price_gold_coins?: number;
|
||||
is_auction?: boolean;
|
||||
auction_ends_at?: Date;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateListingData {
|
||||
quantity?: number;
|
||||
price_pix?: number;
|
||||
price_tibia_coins?: number;
|
||||
price_gold_coins?: number;
|
||||
description?: string;
|
||||
listing_status?: Listing["listing_status"];
|
||||
current_bid_amount?: number;
|
||||
current_bidder_id?: number;
|
||||
}
|
||||
|
||||
export interface ListingFilters {
|
||||
status?: Listing["listing_status"];
|
||||
server_name?: string;
|
||||
item_id?: number;
|
||||
seller_id?: number;
|
||||
is_auction?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: "created_at" | "price_pix" | "price_tibia_coins" | "price_gold_coins" | "views_count";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo anúncio
|
||||
*/
|
||||
export async function createListing(
|
||||
data: CreateListingData
|
||||
): Promise<Listing> {
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
INSERT INTO listings (
|
||||
seller_id, item_id, item_name, quantity, server_name,
|
||||
price_pix, price_tibia_coins, price_gold_coins,
|
||||
is_auction, auction_ends_at, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
data.seller_id,
|
||||
data.item_id,
|
||||
data.item_name,
|
||||
data.quantity,
|
||||
data.server_name,
|
||||
data.price_pix || null,
|
||||
data.price_tibia_coins || null,
|
||||
data.price_gold_coins || null,
|
||||
data.is_auction || false,
|
||||
data.auction_ends_at || null,
|
||||
data.description || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca anúncio por ID
|
||||
*/
|
||||
export async function getListingById(listingId: number): Promise<Listing | null> {
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
SELECT * FROM listings WHERE listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista anúncios com filtros e paginação
|
||||
*/
|
||||
export async function getListings(
|
||||
filters: ListingFilters = {}
|
||||
): Promise<{ listings: Listing[]; total: number }> {
|
||||
const {
|
||||
status,
|
||||
server_name,
|
||||
item_id,
|
||||
seller_id,
|
||||
is_auction,
|
||||
search,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
} = filters;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`listing_status = $${paramIndex}`);
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (server_name) {
|
||||
conditions.push(`server_name = $${paramIndex}`);
|
||||
params.push(server_name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (item_id) {
|
||||
conditions.push(`item_id = $${paramIndex}`);
|
||||
params.push(item_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (seller_id) {
|
||||
conditions.push(`seller_id = $${paramIndex}`);
|
||||
params.push(seller_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (is_auction !== undefined) {
|
||||
conditions.push(`is_auction = $${paramIndex}`);
|
||||
params.push(is_auction);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(item_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
// Contar total
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM listings ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Validar sortBy
|
||||
const validSortColumns = ["created_at", "price_pix", "price_tibia_coins", "price_gold_coins", "views_count"];
|
||||
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : "created_at";
|
||||
const sortDirection = sortOrder === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
// Buscar listings
|
||||
const offset = (page - 1) * pageSize;
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
SELECT * FROM listings
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumn} ${sortDirection}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
listings: result.rows,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza um anúncio
|
||||
*/
|
||||
export async function updateListing(
|
||||
listingId: number,
|
||||
data: UpdateListingData
|
||||
): Promise<Listing | null> {
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.quantity !== undefined) {
|
||||
updates.push(`quantity = $${paramIndex}`);
|
||||
params.push(data.quantity);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.price_pix !== undefined) {
|
||||
updates.push(`price_pix = $${paramIndex}`);
|
||||
params.push(data.price_pix);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.price_tibia_coins !== undefined) {
|
||||
updates.push(`price_tibia_coins = $${paramIndex}`);
|
||||
params.push(data.price_tibia_coins);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.price_gold_coins !== undefined) {
|
||||
updates.push(`price_gold_coins = $${paramIndex}`);
|
||||
params.push(data.price_gold_coins);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
updates.push(`description = $${paramIndex}`);
|
||||
params.push(data.description);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.listing_status !== undefined) {
|
||||
updates.push(`listing_status = $${paramIndex}`);
|
||||
params.push(data.listing_status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.current_bid_amount !== undefined) {
|
||||
updates.push(`current_bid_amount = $${paramIndex}`);
|
||||
params.push(data.current_bid_amount);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.current_bidder_id !== undefined) {
|
||||
updates.push(`current_bidder_id = $${paramIndex}`);
|
||||
params.push(data.current_bidder_id);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return getListingById(listingId);
|
||||
}
|
||||
|
||||
params.push(listingId);
|
||||
const result = await query<Listing>(
|
||||
`
|
||||
UPDATE listings
|
||||
SET ${updates.join(", ")}
|
||||
WHERE listing_id = $${paramIndex}
|
||||
RETURNING *
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela um anúncio (soft delete - muda status)
|
||||
*/
|
||||
export async function deleteListing(listingId: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE listings
|
||||
SET listing_status = 'cancelled'
|
||||
WHERE listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa contador de views
|
||||
*/
|
||||
export async function incrementViews(listingId: number): Promise<void> {
|
||||
await query(
|
||||
`
|
||||
UPDATE listings
|
||||
SET views_count = views_count + 1
|
||||
WHERE listing_id = $1
|
||||
`,
|
||||
[listingId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca anúncios de um vendedor
|
||||
*/
|
||||
export async function getListingsBySeller(
|
||||
sellerId: number,
|
||||
filters: { status?: Listing["listing_status"]; page?: number; pageSize?: number } = {}
|
||||
): Promise<{ listings: Listing[]; total: number }> {
|
||||
return getListings({
|
||||
seller_id: sellerId,
|
||||
...filters,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca apenas anúncios ativos
|
||||
*/
|
||||
export async function getActiveListings(
|
||||
filters: Omit<ListingFilters, "status"> = {}
|
||||
): Promise<{ listings: Listing[]; total: number }> {
|
||||
return getListings({
|
||||
...filters,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Queries de servidores
|
||||
* CRUD completo para a tabela servers
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface Server {
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null;
|
||||
location: "NA" | "SA" | "EU" | "OC" | "BR" | null;
|
||||
is_active: boolean;
|
||||
total_listings: number;
|
||||
total_users: number;
|
||||
total_subscribers: number;
|
||||
total_transactions: number;
|
||||
total_volume: number;
|
||||
average_listing_price: number;
|
||||
last_activity_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
total_listings?: number;
|
||||
total_users?: number;
|
||||
total_subscribers?: number;
|
||||
total_transactions?: number;
|
||||
total_volume?: number;
|
||||
average_listing_price?: number;
|
||||
last_activity_at?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os servidores
|
||||
*/
|
||||
export async function getAllServers(): Promise<Server[]> {
|
||||
const result = await query<Server>(
|
||||
`SELECT * FROM servers ORDER BY server_name ASC`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista apenas servidores ativos
|
||||
*/
|
||||
export async function getActiveServers(): Promise<Server[]> {
|
||||
const result = await query<Server>(
|
||||
`SELECT * FROM servers WHERE is_active = TRUE ORDER BY server_name ASC`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca servidor por nome
|
||||
*/
|
||||
export async function getServerByName(serverName: string): Promise<Server | null> {
|
||||
const result = await query<Server>(
|
||||
`SELECT * FROM servers WHERE server_name = $1`,
|
||||
[serverName]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca servidor por ID
|
||||
*/
|
||||
export async function getServerById(serverId: number): Promise<Server | null> {
|
||||
const result = await query<Server>(
|
||||
`SELECT * FROM servers WHERE server_id = $1`,
|
||||
[serverId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se servidor existe e está ativo
|
||||
*/
|
||||
export async function isServerActive(serverName: string): Promise<boolean> {
|
||||
const result = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM servers WHERE server_name = $1 AND is_active = TRUE`,
|
||||
[serverName]
|
||||
);
|
||||
return parseInt(result.rows[0].count) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa estatísticas do servidor
|
||||
*/
|
||||
export async function incrementServerStats(
|
||||
serverName: string,
|
||||
stats: Partial<{
|
||||
total_listings: number;
|
||||
total_users: number;
|
||||
total_subscribers: number;
|
||||
total_transactions: number;
|
||||
total_volume: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (stats.total_listings !== undefined) {
|
||||
updates.push(`total_listings = total_listings + $${paramIndex++}`);
|
||||
values.push(stats.total_listings);
|
||||
}
|
||||
if (stats.total_users !== undefined) {
|
||||
updates.push(`total_users = total_users + $${paramIndex++}`);
|
||||
values.push(stats.total_users);
|
||||
}
|
||||
if (stats.total_subscribers !== undefined) {
|
||||
updates.push(`total_subscribers = total_subscribers + $${paramIndex++}`);
|
||||
values.push(stats.total_subscribers);
|
||||
}
|
||||
if (stats.total_transactions !== undefined) {
|
||||
updates.push(`total_transactions = total_transactions + $${paramIndex++}`);
|
||||
values.push(stats.total_transactions);
|
||||
}
|
||||
if (stats.total_volume !== undefined) {
|
||||
updates.push(`total_volume = total_volume + $${paramIndex++}`);
|
||||
values.push(stats.total_volume);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sempre atualizar last_activity_at
|
||||
updates.push(`last_activity_at = CURRENT_TIMESTAMP`);
|
||||
values.push(serverName);
|
||||
|
||||
await query(
|
||||
`UPDATE servers SET ${updates.join(", ")} WHERE server_name = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza estatísticas completas do servidor
|
||||
*/
|
||||
export async function updateServerStats(
|
||||
serverName: string,
|
||||
stats: ServerStats
|
||||
): Promise<void> {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (stats.total_listings !== undefined) {
|
||||
updates.push(`total_listings = $${paramIndex++}`);
|
||||
values.push(stats.total_listings);
|
||||
}
|
||||
if (stats.total_users !== undefined) {
|
||||
updates.push(`total_users = $${paramIndex++}`);
|
||||
values.push(stats.total_users);
|
||||
}
|
||||
if (stats.total_subscribers !== undefined) {
|
||||
updates.push(`total_subscribers = $${paramIndex++}`);
|
||||
values.push(stats.total_subscribers);
|
||||
}
|
||||
if (stats.total_transactions !== undefined) {
|
||||
updates.push(`total_transactions = $${paramIndex++}`);
|
||||
values.push(stats.total_transactions);
|
||||
}
|
||||
if (stats.total_volume !== undefined) {
|
||||
updates.push(`total_volume = $${paramIndex++}`);
|
||||
values.push(stats.total_volume);
|
||||
}
|
||||
if (stats.average_listing_price !== undefined) {
|
||||
updates.push(`average_listing_price = $${paramIndex++}`);
|
||||
values.push(stats.average_listing_price);
|
||||
}
|
||||
if (stats.last_activity_at !== undefined) {
|
||||
updates.push(`last_activity_at = $${paramIndex++}`);
|
||||
values.push(stats.last_activity_at);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
values.push(serverName);
|
||||
|
||||
await query(
|
||||
`UPDATE servers SET ${updates.join(", ")} WHERE server_name = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa total_users de múltiplos servidores
|
||||
*/
|
||||
export async function incrementUsersForServers(serverNames: string[]): Promise<void> {
|
||||
if (serverNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE servers
|
||||
SET total_users = total_users + 1,
|
||||
last_activity_at = CURRENT_TIMESTAMP
|
||||
WHERE server_name = ANY($1::text[])`,
|
||||
[serverNames]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Queries de transactions (transações do sistema de escrow)
|
||||
* Operações para a tabela transactions
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface Transaction {
|
||||
transaction_id: number;
|
||||
listing_id: number | null;
|
||||
buyer_id: number;
|
||||
seller_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
amount: number;
|
||||
payment_method: "pix" | "tibia_coins" | "gold_coins" | null;
|
||||
escrow_fee: number | null;
|
||||
transaction_status:
|
||||
| "pending"
|
||||
| "deposited"
|
||||
| "item_delivered"
|
||||
| "completed"
|
||||
| "disputed"
|
||||
| "cancelled"
|
||||
| "refunded";
|
||||
created_at: Date;
|
||||
deposited_at: Date | null;
|
||||
delivered_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
buyer_confirmed: boolean;
|
||||
seller_confirmed: boolean;
|
||||
buyer_notes: string | null;
|
||||
seller_notes: string | null;
|
||||
}
|
||||
|
||||
export interface CreateTransactionData {
|
||||
listing_id?: number;
|
||||
buyer_id: number;
|
||||
seller_id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
server_name: string;
|
||||
amount: number;
|
||||
payment_method: "pix" | "tibia_coins" | "gold_coins";
|
||||
escrow_fee?: number;
|
||||
}
|
||||
|
||||
export interface UpdateTransactionStatusData {
|
||||
transaction_status: Transaction["transaction_status"];
|
||||
deposited_at?: Date;
|
||||
delivered_at?: Date;
|
||||
completed_at?: Date;
|
||||
buyer_confirmed?: boolean;
|
||||
seller_confirmed?: boolean;
|
||||
buyer_notes?: string;
|
||||
seller_notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma nova transação
|
||||
*/
|
||||
export async function createTransaction(
|
||||
data: CreateTransactionData
|
||||
): Promise<Transaction> {
|
||||
// Calcular escrow_fee se não fornecido (2% do valor)
|
||||
const escrowFee = data.escrow_fee ?? Number((data.amount * 0.02).toFixed(2));
|
||||
|
||||
const result = await query<Transaction>(
|
||||
`
|
||||
INSERT INTO transactions (
|
||||
listing_id, buyer_id, seller_id, item_name, quantity,
|
||||
server_name, amount, payment_method, escrow_fee
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
data.listing_id || null,
|
||||
data.buyer_id,
|
||||
data.seller_id,
|
||||
data.item_name,
|
||||
data.quantity,
|
||||
data.server_name,
|
||||
data.amount,
|
||||
data.payment_method,
|
||||
escrowFee,
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca transação por ID
|
||||
*/
|
||||
export async function getTransactionById(
|
||||
transactionId: number
|
||||
): Promise<Transaction | null> {
|
||||
const result = await query<Transaction>(
|
||||
`
|
||||
SELECT * FROM transactions WHERE transaction_id = $1
|
||||
`,
|
||||
[transactionId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza status da transação
|
||||
*/
|
||||
export async function updateTransactionStatus(
|
||||
transactionId: number,
|
||||
data: UpdateTransactionStatusData
|
||||
): Promise<Transaction | null> {
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
updates.push(`transaction_status = $${paramIndex}`);
|
||||
params.push(data.transaction_status);
|
||||
paramIndex++;
|
||||
|
||||
if (data.deposited_at !== undefined) {
|
||||
updates.push(`deposited_at = $${paramIndex}`);
|
||||
params.push(data.deposited_at);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.delivered_at !== undefined) {
|
||||
updates.push(`delivered_at = $${paramIndex}`);
|
||||
params.push(data.delivered_at);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.completed_at !== undefined) {
|
||||
updates.push(`completed_at = $${paramIndex}`);
|
||||
params.push(data.completed_at);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.buyer_confirmed !== undefined) {
|
||||
updates.push(`buyer_confirmed = $${paramIndex}`);
|
||||
params.push(data.buyer_confirmed);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.seller_confirmed !== undefined) {
|
||||
updates.push(`seller_confirmed = $${paramIndex}`);
|
||||
params.push(data.seller_confirmed);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.buyer_notes !== undefined) {
|
||||
updates.push(`buyer_notes = $${paramIndex}`);
|
||||
params.push(data.buyer_notes);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.seller_notes !== undefined) {
|
||||
updates.push(`seller_notes = $${paramIndex}`);
|
||||
params.push(data.seller_notes);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
params.push(transactionId);
|
||||
const result = await query<Transaction>(
|
||||
`
|
||||
UPDATE transactions
|
||||
SET ${updates.join(", ")}
|
||||
WHERE transaction_id = $${paramIndex}
|
||||
RETURNING *
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca transações de um usuário (comprador ou vendedor)
|
||||
*/
|
||||
export async function getTransactionsByUser(
|
||||
userId: number,
|
||||
filters: {
|
||||
role?: "buyer" | "seller" | "both";
|
||||
status?: Transaction["transaction_status"];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}
|
||||
): Promise<{ transactions: Transaction[]; total: number }> {
|
||||
const { role = "both", status, page = 1, pageSize = 20 } = filters;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (role === "buyer") {
|
||||
conditions.push(`buyer_id = $${paramIndex}`);
|
||||
} else if (role === "seller") {
|
||||
conditions.push(`seller_id = $${paramIndex}`);
|
||||
} else {
|
||||
conditions.push(`(buyer_id = $${paramIndex} OR seller_id = $${paramIndex})`);
|
||||
}
|
||||
params.push(userId);
|
||||
paramIndex++;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`transaction_status = $${paramIndex}`);
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
// Contar total
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM transactions ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Buscar transações
|
||||
const offset = (page - 1) * pageSize;
|
||||
const result = await query<Transaction>(
|
||||
`
|
||||
SELECT * FROM transactions
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
transactions: result.rows,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Queries de usuários
|
||||
* CRUD completo para a tabela users
|
||||
*/
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface User {
|
||||
user_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash?: string; // Apenas para login, não retornar em outros lugares
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
cpf: string;
|
||||
account_level: "free" | "bronze" | "silver" | "gold";
|
||||
has_donated: boolean;
|
||||
whatsapp_notifications: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
last_login: Date | null;
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone?: string;
|
||||
cpf?: string; // CPF agora é opcional
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
whatsapp_notifications?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um novo usuário
|
||||
*/
|
||||
export async function createUser(
|
||||
data: CreateUserData
|
||||
): Promise<{ userId: number; username: string; email: string }> {
|
||||
const result = await query<{ user_id: number; username: string; email: string }>(
|
||||
`
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, phone, cpf)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING user_id, username, email
|
||||
`,
|
||||
[
|
||||
data.username,
|
||||
data.email,
|
||||
data.password_hash,
|
||||
data.first_name,
|
||||
data.last_name,
|
||||
data.phone || null,
|
||||
data.cpf || null,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
userId: result.rows[0].user_id,
|
||||
username: result.rows[0].username,
|
||||
email: result.rows[0].email,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por email (inclui password_hash para login)
|
||||
*/
|
||||
export async function getUserByEmail(email: string): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
`
|
||||
SELECT * FROM users WHERE email = $1
|
||||
`,
|
||||
[email]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por username
|
||||
*/
|
||||
export async function getUserByUsername(
|
||||
username: string
|
||||
): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
`
|
||||
SELECT * FROM users WHERE username = $1
|
||||
`,
|
||||
[username]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por ID (sem password_hash)
|
||||
*/
|
||||
export async function getUserById(userId: number): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
`
|
||||
SELECT
|
||||
user_id, username, email, first_name, last_name, phone, cpf,
|
||||
account_level, has_donated, whatsapp_notifications,
|
||||
created_at, updated_at, last_login, is_active, is_verified
|
||||
FROM users
|
||||
WHERE user_id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca usuário por CPF (verifica duplicidade)
|
||||
*/
|
||||
export async function getUserByCpf(cpf: string): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
`
|
||||
SELECT user_id, username, email FROM users WHERE cpf = $1
|
||||
`,
|
||||
[cpf]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza dados do usuário
|
||||
*/
|
||||
export async function updateUser(
|
||||
userId: number,
|
||||
data: UpdateUserData
|
||||
): Promise<User> {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.first_name !== undefined) {
|
||||
updates.push(`first_name = $${paramIndex++}`);
|
||||
values.push(data.first_name);
|
||||
}
|
||||
|
||||
if (data.last_name !== undefined) {
|
||||
updates.push(`last_name = $${paramIndex++}`);
|
||||
values.push(data.last_name);
|
||||
}
|
||||
|
||||
if (data.phone !== undefined) {
|
||||
updates.push(`phone = $${paramIndex++}`);
|
||||
values.push(data.phone || null);
|
||||
}
|
||||
|
||||
if (data.whatsapp_notifications !== undefined) {
|
||||
updates.push(`whatsapp_notifications = $${paramIndex++}`);
|
||||
values.push(data.whatsapp_notifications);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
// Se não há updates, retorna usuário atual
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
throw new Error("Usuário não encontrado");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
values.push(userId);
|
||||
|
||||
const result = await query<User>(
|
||||
`
|
||||
UPDATE users
|
||||
SET ${updates.join(", ")}
|
||||
WHERE user_id = $${paramIndex}
|
||||
RETURNING
|
||||
user_id, username, email, first_name, last_name, phone, cpf,
|
||||
account_level, has_donated, whatsapp_notifications,
|
||||
created_at, updated_at, last_login, is_active, is_verified
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error("Usuário não encontrado");
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza timestamp de último login
|
||||
*/
|
||||
export async function updateLastLogin(userId: number): Promise<void> {
|
||||
await query(
|
||||
`
|
||||
UPDATE users
|
||||
SET last_login = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Seed inicial de servidores do Tibia
|
||||
* Lista completa de servidores oficiais
|
||||
*/
|
||||
|
||||
// Carregar variáveis de ambiente
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Tentar carregar .env.local primeiro, depois .env
|
||||
config({ path: resolve(process.cwd(), ".env.local") });
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
import { query } from "../index";
|
||||
|
||||
export interface ServerSeed {
|
||||
server_name: string;
|
||||
server_type: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp";
|
||||
location: "NA" | "SA" | "EU" | "OC" | "BR";
|
||||
}
|
||||
|
||||
// Lista de servidores oficiais do Tibia (servidores únicos)
|
||||
// Nota: Cada servidor tem um nome único. O tipo pode ser atualizado posteriormente se necessário.
|
||||
const TIBIA_SERVERS: ServerSeed[] = [
|
||||
// Servidores PvP - EU
|
||||
{ server_name: "Antica", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Assombra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Astera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Belobra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Bona", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Calmera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Celesta", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Concorda", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Cosera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Damora", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Descubra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Dia", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Duna", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Elera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Epoca", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Esmera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Faluna", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Famosa", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Fera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Ferobra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Firmera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Funera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Furia", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Garnera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Gentebra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Gladera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Gravitera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Grimera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Harmonia", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Honbra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Impulsa", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Inabra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Issobra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Jacabra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Javibra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Jonera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Kalibra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Kardera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Kenora", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Libertabra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Lobera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Luminera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Lutabra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Menera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Mitigera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Monza", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Nefera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Noctera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Ocera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Ombra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Pacera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Peloria", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Premia", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Pyra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Quelibra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Quintera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Refugia", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Relania", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Secura", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Serdebra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Solidera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Talonera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Tortura", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Unica", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Utobra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Venebra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Vita", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Vunira", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Wadira", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Wintera", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Xandebra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Yonabra", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Zuna", server_type: "pvp", location: "EU" },
|
||||
{ server_name: "Zunera", server_type: "pvp", location: "EU" },
|
||||
|
||||
// Servidores No-PvP - EU
|
||||
{ server_name: "Asteria", server_type: "no-pvp", location: "EU" },
|
||||
{ server_name: "Belluma", server_type: "no-pvp", location: "EU" },
|
||||
{ server_name: "Calva", server_type: "no-pvp", location: "EU" },
|
||||
{ server_name: "Castela", server_type: "no-pvp", location: "EU" },
|
||||
{ server_name: "Collabra", server_type: "no-pvp", location: "EU" },
|
||||
{ server_name: "Estela", server_type: "no-pvp", location: "EU" },
|
||||
];
|
||||
|
||||
export async function seedServers() {
|
||||
console.log("🌱 Iniciando seed de servidores...\n");
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const server of TIBIA_SERVERS) {
|
||||
try {
|
||||
// Verificar se já existe
|
||||
const existing = await query<{ server_id: number }>(
|
||||
"SELECT server_id FROM servers WHERE server_name = $1",
|
||||
[server.server_name]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inserir servidor
|
||||
await query(
|
||||
`INSERT INTO servers (server_name, server_type, location, is_active)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[server.server_name, server.server_type, server.location, true]
|
||||
);
|
||||
|
||||
inserted++;
|
||||
} catch (error) {
|
||||
console.error(`❌ Erro ao inserir servidor ${server.server_name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Seed concluído: ${inserted} servidores inseridos, ${skipped} já existiam\n`);
|
||||
}
|
||||
|
||||
// Se executado diretamente
|
||||
if (require.main === module) {
|
||||
seedServers()
|
||||
.then(() => {
|
||||
console.log("🎉 Seed finalizado com sucesso!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ Erro fatal no seed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Script de teste de conexão com PostgreSQL
|
||||
* Executa um health check e tenta uma query simples
|
||||
*/
|
||||
|
||||
// Carregar variáveis de ambiente
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Tentar carregar .env.local primeiro, depois .env
|
||||
config({ path: resolve(process.cwd(), ".env.local") });
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
import { healthCheck, query, closePool } from "./index";
|
||||
|
||||
async function testConnection() {
|
||||
console.log("🔍 Testando conexão com PostgreSQL...\n");
|
||||
|
||||
// Verificar variável de ambiente
|
||||
if (!process.env.PG_DB) {
|
||||
console.error("❌ PG_DB não está definido nas variáveis de ambiente");
|
||||
console.log("\n💡 Configure a variável PG_DB:");
|
||||
console.log(" export PG_DB=postgresql://user:pass@host:port/db");
|
||||
console.log(" ou crie um arquivo .env.local com:");
|
||||
console.log(" PG_DB=postgresql://user:pass@host:port/db\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("✅ Variável PG_DB encontrada");
|
||||
console.log(` Host: ${process.env.PG_DB.split("@")[1]?.split("/")[0] || "não detectado"}\n`);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
console.log("📡 Executando health check...");
|
||||
const isHealthy = await healthCheck();
|
||||
|
||||
if (!isHealthy) {
|
||||
console.error("❌ Health check falhou - banco não está acessível");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("✅ Health check passou\n");
|
||||
|
||||
// Query de teste
|
||||
console.log("📊 Executando query de teste...");
|
||||
const result = await query("SELECT version(), NOW() as current_time");
|
||||
|
||||
console.log("✅ Query executada com sucesso");
|
||||
console.log(` PostgreSQL Version: ${result.rows[0]?.version?.split(" ")[0] || "N/A"}`);
|
||||
console.log(` Current Time: ${result.rows[0]?.current_time || "N/A"}\n`);
|
||||
|
||||
// Verificar se as tabelas existem
|
||||
console.log("🔍 Verificando tabelas...");
|
||||
const tablesResult = await query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('users', 'user_characters')
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
const tables = tablesResult.rows.map((row: any) => row.table_name);
|
||||
|
||||
if (tables.length === 0) {
|
||||
console.log("⚠️ Tabelas não encontradas. Execute as migrations:");
|
||||
console.log(" pnpm db:migrate\n");
|
||||
} else {
|
||||
console.log(`✅ Tabelas encontradas: ${tables.join(", ")}\n`);
|
||||
}
|
||||
|
||||
console.log("🎉 Conexão com PostgreSQL está funcionando corretamente!");
|
||||
|
||||
// Fechar pool
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao conectar com PostgreSQL:");
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
|
||||
if (error instanceof Error && error.message.includes("timeout")) {
|
||||
console.log("\n💡 Verifique:");
|
||||
console.log(" - O servidor PostgreSQL está acessível?");
|
||||
console.log(" - A URL de conexão está correta?");
|
||||
console.log(" - O firewall permite conexões?");
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes("authentication")) {
|
||||
console.log("\n💡 Verifique:");
|
||||
console.log(" - Usuário e senha estão corretos?");
|
||||
console.log(" - O usuário tem permissão para acessar o banco?");
|
||||
}
|
||||
|
||||
await closePool();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
testConnection();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export interface Server {
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
serverType: "pvp" | "no-pvp" | "retro-pvp" | "hardcore-pvp" | null;
|
||||
location: "NA" | "SA" | "EU" | "OC" | "BR" | null;
|
||||
isActive: boolean;
|
||||
stats: {
|
||||
totalListings: number;
|
||||
totalUsers: number;
|
||||
totalSubscribers: number;
|
||||
totalTransactions: number;
|
||||
totalVolume: number;
|
||||
averageListingPrice: number;
|
||||
lastActivityAt: Date | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseServersReturn {
|
||||
servers: Server[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para buscar e gerenciar servidores disponíveis
|
||||
*/
|
||||
export function useServers(): UseServersReturn {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
// Só buscar no cliente
|
||||
if (typeof window === "undefined") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/servers", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Verificar se a resposta é JSON
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
console.error("Resposta não é JSON:", text.substring(0, 200));
|
||||
setError("Erro: resposta inválida do servidor");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setServers(data.servers || []);
|
||||
} else {
|
||||
setError(data.error || "Erro ao buscar servidores");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Erro desconhecido";
|
||||
setError("Erro ao conectar com o servidor");
|
||||
console.error("Erro ao buscar servidores:", errorMessage, err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchServers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export interface SessionUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
user: SessionUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para gerenciar sessão do usuário
|
||||
*/
|
||||
export function useSession() {
|
||||
const [session, setSession] = useState<SessionState>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Verifica a sessão atual
|
||||
*/
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.authenticated) {
|
||||
setSession({
|
||||
user: data.user,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
setSession({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao verificar sessão:", error);
|
||||
setSession({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Encerra a sessão
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
setSession({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
// Recarregar página para limpar estado
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer logout:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Atualiza a sessão após login
|
||||
*/
|
||||
const refreshSession = useCallback(async () => {
|
||||
await checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
// Verificar sessão ao montar componente
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
isLoading: session.isLoading,
|
||||
isAuthenticated: session.isAuthenticated,
|
||||
logout,
|
||||
refreshSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,22 @@ const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
maxRequests: 100,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
"/api/auth/register": {
|
||||
maxRequests: 5,
|
||||
windowMs: 10 * 60 * 1000, // 10 minutos
|
||||
},
|
||||
"/api/auth/login": {
|
||||
maxRequests: 10,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
"/api/auth/refresh": {
|
||||
maxRequests: 20,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
"/api/auth/me": {
|
||||
maxRequests: 30,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
};
|
||||
|
||||
// Limpar cache expirado a cada 5 minutos
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Módulo de conexão com Redis
|
||||
* Gerencia conexão e fornece helpers para cache e sessões
|
||||
*/
|
||||
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
|
||||
let client: RedisClientType | null = null;
|
||||
|
||||
/**
|
||||
* Valida variáveis de ambiente necessárias
|
||||
*/
|
||||
function validateEnvVars() {
|
||||
if (!process.env.REDIS_DB) {
|
||||
throw new Error(
|
||||
"REDIS_DB não está definido. Configure a variável de ambiente REDIS_DB com a connection string do Redis."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém ou cria o cliente Redis
|
||||
*/
|
||||
export function getRedisClient(): RedisClientType {
|
||||
if (!client) {
|
||||
validateEnvVars();
|
||||
|
||||
const connectionString = process.env.REDIS_DB;
|
||||
|
||||
client = createClient({
|
||||
url: connectionString,
|
||||
});
|
||||
|
||||
// Event listeners para debug
|
||||
client.on("error", (err) => {
|
||||
console.error("❌ Erro no cliente Redis:", err);
|
||||
});
|
||||
|
||||
client.on("connect", () => {
|
||||
console.log("✅ Conectado ao Redis");
|
||||
});
|
||||
|
||||
client.on("ready", () => {
|
||||
console.log("✅ Redis está pronto para uso");
|
||||
});
|
||||
|
||||
// Conectar
|
||||
client.connect().catch((err) => {
|
||||
console.error("❌ Erro ao conectar com Redis:", err);
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se Redis está conectado
|
||||
*/
|
||||
export async function isRedisConnected(): Promise<boolean> {
|
||||
try {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient.isOpen) {
|
||||
await redisClient.connect();
|
||||
}
|
||||
await redisClient.ping();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ Redis não está conectado:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fecha conexão com Redis
|
||||
*/
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (client && client.isOpen) {
|
||||
await client.quit();
|
||||
client = null;
|
||||
console.log("🔌 Conexão Redis fechada");
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup ao encerrar o processo
|
||||
if (typeof process !== "undefined") {
|
||||
process.on("SIGINT", async () => {
|
||||
await closeRedis();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await closeRedis();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Sistema de sessão usando Redis
|
||||
* Armazena informações de sessão do usuário
|
||||
*/
|
||||
|
||||
import { getRedisClient } from "./index";
|
||||
|
||||
const SESSION_PREFIX = "session:";
|
||||
const USER_SESSION_PREFIX = "user_session:";
|
||||
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 dias (mesmo que refresh token)
|
||||
|
||||
export interface SessionData {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria ou atualiza uma sessão
|
||||
*/
|
||||
export async function setSession(
|
||||
sessionId: string,
|
||||
data: SessionData
|
||||
): Promise<void> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
const key = `${SESSION_PREFIX}${sessionId}`;
|
||||
await client.setEx(key, SESSION_TTL, JSON.stringify(data));
|
||||
|
||||
// Também armazenar referência por userId para invalidar todas as sessões
|
||||
const userKey = `${USER_SESSION_PREFIX}${data.userId}`;
|
||||
await client.sAdd(userKey, sessionId);
|
||||
await client.expire(userKey, SESSION_TTL);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao criar sessão:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém dados de uma sessão
|
||||
*/
|
||||
export async function getSession(
|
||||
sessionId: string
|
||||
): Promise<SessionData | null> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
const key = `${SESSION_PREFIX}${sessionId}`;
|
||||
const data = await client.get(key);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(data) as SessionData;
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao obter sessão:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma sessão
|
||||
*/
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
const key = `${SESSION_PREFIX}${sessionId}`;
|
||||
const data = await client.get(key);
|
||||
|
||||
if (data) {
|
||||
const sessionData = JSON.parse(data) as SessionData;
|
||||
const userKey = `${USER_SESSION_PREFIX}${sessionData.userId}`;
|
||||
await client.sRem(userKey, sessionId);
|
||||
}
|
||||
|
||||
await client.del(key);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao remover sessão:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove todas as sessões de um usuário
|
||||
*/
|
||||
export async function deleteAllUserSessions(userId: number): Promise<void> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
const userKey = `${USER_SESSION_PREFIX}${userId}`;
|
||||
const sessionIds = await client.sMembers(userKey);
|
||||
|
||||
if (sessionIds.length > 0) {
|
||||
const keys = sessionIds.map((id) => `${SESSION_PREFIX}${id}`);
|
||||
await client.del(keys);
|
||||
await client.del(userKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao remover todas as sessões do usuário:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza TTL de uma sessão
|
||||
*/
|
||||
export async function refreshSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
const key = `${SESSION_PREFIX}${sessionId}`;
|
||||
const exists = await client.exists(key);
|
||||
|
||||
if (exists) {
|
||||
await client.expire(key, SESSION_TTL);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao atualizar TTL da sessão:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Lista de países com códigos de área para seleção de telefone
|
||||
*/
|
||||
|
||||
export interface Country {
|
||||
code: string; // Código ISO 3166-1 alpha-2
|
||||
name: string; // Nome do país
|
||||
dialCode: string; // Código de discagem internacional
|
||||
flag: string; // Emoji da bandeira
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de países mais comuns com seus códigos de discagem
|
||||
* Ordenado por popularidade/uso
|
||||
*/
|
||||
export const COUNTRIES: Country[] = [
|
||||
{ code: "BR", name: "Brasil", dialCode: "+55", flag: "🇧🇷" },
|
||||
{ code: "US", name: "Estados Unidos", dialCode: "+1", flag: "🇺🇸" },
|
||||
{ code: "CA", name: "Canadá", dialCode: "+1", flag: "🇨🇦" },
|
||||
{ code: "MX", name: "México", dialCode: "+52", flag: "🇲🇽" },
|
||||
{ code: "AR", name: "Argentina", dialCode: "+54", flag: "🇦🇷" },
|
||||
{ code: "CL", name: "Chile", dialCode: "+56", flag: "🇨🇱" },
|
||||
{ code: "CO", name: "Colômbia", dialCode: "+57", flag: "🇨🇴" },
|
||||
{ code: "PE", name: "Peru", dialCode: "+51", flag: "🇵🇪" },
|
||||
{ code: "PT", name: "Portugal", dialCode: "+351", flag: "🇵🇹" },
|
||||
{ code: "ES", name: "Espanha", dialCode: "+34", flag: "🇪🇸" },
|
||||
{ code: "GB", name: "Reino Unido", dialCode: "+44", flag: "🇬🇧" },
|
||||
{ code: "FR", name: "França", dialCode: "+33", flag: "🇫🇷" },
|
||||
{ code: "DE", name: "Alemanha", dialCode: "+49", flag: "🇩🇪" },
|
||||
{ code: "IT", name: "Itália", dialCode: "+39", flag: "🇮🇹" },
|
||||
{ code: "NL", name: "Holanda", dialCode: "+31", flag: "🇳🇱" },
|
||||
{ code: "BE", name: "Bélgica", dialCode: "+32", flag: "🇧🇪" },
|
||||
{ code: "CH", name: "Suíça", dialCode: "+41", flag: "🇨🇭" },
|
||||
{ code: "AT", name: "Áustria", dialCode: "+43", flag: "🇦🇹" },
|
||||
{ code: "PL", name: "Polônia", dialCode: "+48", flag: "🇵🇱" },
|
||||
{ code: "RU", name: "Rússia", dialCode: "+7", flag: "🇷🇺" },
|
||||
{ code: "CN", name: "China", dialCode: "+86", flag: "🇨🇳" },
|
||||
{ code: "JP", name: "Japão", dialCode: "+81", flag: "🇯🇵" },
|
||||
{ code: "KR", name: "Coreia do Sul", dialCode: "+82", flag: "🇰🇷" },
|
||||
{ code: "IN", name: "Índia", dialCode: "+91", flag: "🇮🇳" },
|
||||
{ code: "AU", name: "Austrália", dialCode: "+61", flag: "🇦🇺" },
|
||||
{ code: "NZ", name: "Nova Zelândia", dialCode: "+64", flag: "🇳🇿" },
|
||||
{ code: "ZA", name: "África do Sul", dialCode: "+27", flag: "🇿🇦" },
|
||||
{ code: "EG", name: "Egito", dialCode: "+20", flag: "🇪🇬" },
|
||||
{ code: "TR", name: "Turquia", dialCode: "+90", flag: "🇹🇷" },
|
||||
{ code: "SA", name: "Arábia Saudita", dialCode: "+966", flag: "🇸🇦" },
|
||||
{ code: "AE", name: "Emirados Árabes", dialCode: "+971", flag: "🇦🇪" },
|
||||
{ code: "IL", name: "Israel", dialCode: "+972", flag: "🇮🇱" },
|
||||
{ code: "UY", name: "Uruguai", dialCode: "+598", flag: "🇺🇾" },
|
||||
{ code: "PY", name: "Paraguai", dialCode: "+595", flag: "🇵🇾" },
|
||||
{ code: "BO", name: "Bolívia", dialCode: "+591", flag: "🇧🇴" },
|
||||
{ code: "VE", name: "Venezuela", dialCode: "+58", flag: "🇻🇪" },
|
||||
{ code: "EC", name: "Equador", dialCode: "+593", flag: "🇪🇨" },
|
||||
];
|
||||
|
||||
/**
|
||||
* País padrão (Brasil)
|
||||
*/
|
||||
export const DEFAULT_COUNTRY: Country = COUNTRIES[0];
|
||||
|
||||
/**
|
||||
* Busca país por código
|
||||
*/
|
||||
export function getCountryByCode(code: string): Country | undefined {
|
||||
return COUNTRIES.find((c) => c.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca país por código de discagem
|
||||
*/
|
||||
export function getCountryByDialCode(dialCode: string): Country | undefined {
|
||||
return COUNTRIES.find((c) => c.dialCode === dialCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Funções utilitárias para formatação de dados
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formata número de telefone brasileiro
|
||||
* Exemplo: 11988887777 -> (11) 98888-7777
|
||||
*/
|
||||
export function formatPhone(value: string): string {
|
||||
const cleaned = value.replace(/\D/g, "");
|
||||
|
||||
if (cleaned.length <= 2) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
if (cleaned.length <= 6) {
|
||||
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2)}`;
|
||||
}
|
||||
|
||||
if (cleaned.length <= 10) {
|
||||
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
|
||||
// Telefone com 11 dígitos (celular)
|
||||
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata número de telefone específico por país
|
||||
* @param phoneNumber - Número do telefone (apenas dígitos, sem código de país)
|
||||
* @param countryCode - Código ISO do país (ex: "BR", "US", "GB")
|
||||
*/
|
||||
function formatPhoneByCountry(phoneNumber: string, countryCode: string): string {
|
||||
const len = phoneNumber.length;
|
||||
|
||||
switch (countryCode) {
|
||||
// Brasil: (XX) XXXXX-XXXX ou (XX) XXXX-XXXX
|
||||
case "BR":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 6) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2)}`;
|
||||
if (len <= 10) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 6)}-${phoneNumber.slice(6)}`;
|
||||
return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 7)}-${phoneNumber.slice(7, 11)}`;
|
||||
|
||||
// EUA/Canadá: (XXX) XXX-XXXX
|
||||
case "US":
|
||||
case "CA":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
|
||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// México: (XX) XXXX XXXX
|
||||
case "MX":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 6) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2)}`;
|
||||
return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 6)} ${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// Argentina: (XX) XXXX-XXXX
|
||||
case "AR":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 6) return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2)}`;
|
||||
return `(${phoneNumber.slice(0, 2)}) ${phoneNumber.slice(2, 6)}-${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// Chile: X XXXX XXXX
|
||||
case "CL":
|
||||
if (len <= 1) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 1)} ${phoneNumber.slice(1)}`;
|
||||
return `${phoneNumber.slice(0, 1)} ${phoneNumber.slice(1, 5)} ${phoneNumber.slice(5, 9)}`;
|
||||
|
||||
// Colômbia: XXX XXX XXXX
|
||||
case "CO":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// Peru: XXX XXX XXX
|
||||
case "PE":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`;
|
||||
|
||||
// Portugal: XXX XXX XXX
|
||||
case "PT":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`;
|
||||
|
||||
// Espanha: XXX XXX XXX
|
||||
case "ES":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`;
|
||||
|
||||
// Reino Unido: XXXX XXX XXXX
|
||||
case "GB":
|
||||
if (len <= 4) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 11)}`;
|
||||
|
||||
// França: XX XX XX XX XX
|
||||
case "FR": {
|
||||
if (len <= 2) return phoneNumber;
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
if (i + 2 <= len) {
|
||||
groups.push(phoneNumber.slice(i, i + 2));
|
||||
} else {
|
||||
groups.push(phoneNumber.slice(i));
|
||||
}
|
||||
}
|
||||
return groups.join(" ");
|
||||
}
|
||||
|
||||
// Alemanha: XXXX XXXXXXX
|
||||
case "DE":
|
||||
if (len <= 4) return phoneNumber;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`;
|
||||
|
||||
// Itália: XXX XXX XXXX
|
||||
case "IT":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// Holanda: XX XXXXXXXX
|
||||
case "NL":
|
||||
if (len <= 2) return phoneNumber;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
|
||||
// Bélgica: XXXX XX XX XX
|
||||
case "BE":
|
||||
if (len <= 4) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`;
|
||||
if (len <= 8) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 6)} ${phoneNumber.slice(6)}`;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 6)} ${phoneNumber.slice(6, 8)} ${phoneNumber.slice(8)}`;
|
||||
|
||||
// Suíça: XX XXX XX XX
|
||||
case "CH":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5)}`;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 7)} ${phoneNumber.slice(7)}`;
|
||||
|
||||
// Áustria: XXXX XXXX
|
||||
case "AT":
|
||||
if (len <= 4) return phoneNumber;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 8)}`;
|
||||
|
||||
// Polônia: XXX XXX XXX
|
||||
case "PL":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`;
|
||||
|
||||
// Rússia: XXX XXX-XX-XX
|
||||
case "RU":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
if (len <= 8) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 8)}-${phoneNumber.slice(8)}`;
|
||||
|
||||
// China: XXX XXXX XXXX
|
||||
case "CN":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 7)} ${phoneNumber.slice(7, 11)}`;
|
||||
|
||||
// Japão: XX-XXXX-XXXX
|
||||
case "JP":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2, 6)}-${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// Coreia do Sul: XXX-XXXX-XXXX
|
||||
case "KR":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 7)}-${phoneNumber.slice(7, 11)}`;
|
||||
|
||||
// Índia: XXXX XXX XXX
|
||||
case "IN":
|
||||
if (len <= 4) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 10)}`;
|
||||
|
||||
// Austrália: XXXX XXX XXX
|
||||
case "AU":
|
||||
if (len <= 4) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 10)}`;
|
||||
|
||||
// Nova Zelândia: XXXX XXX XXX
|
||||
case "NZ":
|
||||
if (len <= 4) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4)}`;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 7)} ${phoneNumber.slice(7, 10)}`;
|
||||
|
||||
// África do Sul: XX XXX XXXX
|
||||
case "ZA":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`;
|
||||
|
||||
// Turquia: XXX XXX XX XX
|
||||
case "TR":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
if (len <= 8) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 8)} ${phoneNumber.slice(8)}`;
|
||||
|
||||
// Arábia Saudita: XX XXX XXXX
|
||||
case "SA":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`;
|
||||
|
||||
// Emirados Árabes: XX XXX XXXX
|
||||
case "AE":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`;
|
||||
|
||||
// Israel: XX-XXX-XXXX
|
||||
case "IL":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)}-${phoneNumber.slice(2, 5)}-${phoneNumber.slice(5, 9)}`;
|
||||
|
||||
// Uruguai: XXXX XXXX
|
||||
case "UY":
|
||||
if (len <= 4) return phoneNumber;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 8)}`;
|
||||
|
||||
// Paraguai: XXX XXX XXX
|
||||
case "PY":
|
||||
if (len <= 3) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6, 9)}`;
|
||||
|
||||
// Bolívia: XXXX XXXX
|
||||
case "BO":
|
||||
if (len <= 4) return phoneNumber;
|
||||
return `${phoneNumber.slice(0, 4)} ${phoneNumber.slice(4, 8)}`;
|
||||
|
||||
// Venezuela: XXXX-XXXXXXX
|
||||
case "VE":
|
||||
if (len <= 4) return phoneNumber;
|
||||
return `${phoneNumber.slice(0, 4)}-${phoneNumber.slice(4)}`;
|
||||
|
||||
// Equador: XX XXX XXXX
|
||||
case "EC":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 5) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 5)} ${phoneNumber.slice(5, 9)}`;
|
||||
|
||||
// Egito: XX XXXX XXXX
|
||||
case "EG":
|
||||
if (len <= 2) return phoneNumber;
|
||||
if (len <= 6) return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2)}`;
|
||||
return `${phoneNumber.slice(0, 2)} ${phoneNumber.slice(2, 6)} ${phoneNumber.slice(6, 10)}`;
|
||||
|
||||
// Formatação genérica padrão (espaços a cada 3-4 dígitos)
|
||||
default: {
|
||||
if (len <= 4) return phoneNumber;
|
||||
if (len <= 7) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3)}`;
|
||||
if (len <= 10) return `${phoneNumber.slice(0, 3)} ${phoneNumber.slice(3, 6)} ${phoneNumber.slice(6)}`;
|
||||
|
||||
// Para números maiores, agrupa em grupos de 3-4 dígitos
|
||||
const groups: string[] = [];
|
||||
let remaining = phoneNumber;
|
||||
|
||||
while (remaining.length > 4) {
|
||||
groups.push(remaining.slice(0, 3));
|
||||
remaining = remaining.slice(3);
|
||||
}
|
||||
|
||||
if (remaining.length > 0) {
|
||||
groups.push(remaining);
|
||||
}
|
||||
|
||||
return groups.join(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera um placeholder de telefone baseado no formato do país
|
||||
* @param countryCode - Código ISO do país (ex: "BR", "US", "GB")
|
||||
* @returns Placeholder formatado (ex: "(11) 98888-7777")
|
||||
*/
|
||||
export function getPhonePlaceholder(countryCode: string): string {
|
||||
// Números exemplo para cada país (apenas para mostrar o formato)
|
||||
const examples: Record<string, string> = {
|
||||
BR: "(11) 98888-7777",
|
||||
US: "(415) 555-1234",
|
||||
CA: "(416) 555-1234",
|
||||
MX: "(55) 1234 5678",
|
||||
AR: "(11) 1234-5678",
|
||||
CL: "9 1234 5678",
|
||||
CO: "300 123 4567",
|
||||
PE: "987 654 321",
|
||||
PT: "912 345 678",
|
||||
ES: "612 345 678",
|
||||
GB: "7123 456 789",
|
||||
FR: "61 23 45 67 89",
|
||||
DE: "3012 3456789",
|
||||
IT: "321 234 5678",
|
||||
NL: "61 23456789",
|
||||
BE: "1234 56 78 90",
|
||||
CH: "21 321 45 67",
|
||||
AT: "1234 5678",
|
||||
PL: "123 456 789",
|
||||
RU: "495 123-45-67",
|
||||
CN: "138 1234 5678",
|
||||
JP: "90-1234-5678",
|
||||
KR: "010-1234-5678",
|
||||
IN: "9876 543 210",
|
||||
AU: "0412 345 678",
|
||||
NZ: "021 234 5678",
|
||||
ZA: "82 123 4567",
|
||||
TR: "532 123 45 67",
|
||||
SA: "50 123 4567",
|
||||
AE: "50 123 4567",
|
||||
IL: "50-123-4567",
|
||||
UY: "1234 5678",
|
||||
PY: "981 234 567",
|
||||
BO: "7123 4567",
|
||||
VE: "2126-1234567",
|
||||
EC: "99 123 4567",
|
||||
EG: "10 1234 5678",
|
||||
};
|
||||
|
||||
return examples[countryCode] || "123 456 7890";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata número de telefone internacional com código de país
|
||||
* @param value - Número do telefone (com ou sem código de país)
|
||||
* @param dialCode - Código de discagem internacional (ex: "+55")
|
||||
* @param countryCode - Código ISO do país (ex: "BR")
|
||||
*/
|
||||
export function formatInternationalPhone(
|
||||
value: string,
|
||||
dialCode: string,
|
||||
countryCode?: string
|
||||
): string {
|
||||
const cleaned = value.replace(/\D/g, "");
|
||||
|
||||
// Remove o código de país se já estiver presente
|
||||
let phoneNumber = cleaned;
|
||||
const dialCodeDigits = dialCode.replace(/\D/g, "");
|
||||
|
||||
if (phoneNumber.startsWith(dialCodeDigits)) {
|
||||
phoneNumber = phoneNumber.slice(dialCodeDigits.length);
|
||||
}
|
||||
|
||||
// Se não temos código de país, usar formatação genérica
|
||||
if (!countryCode) {
|
||||
return formatPhoneByCountry(phoneNumber, "DEFAULT");
|
||||
}
|
||||
|
||||
// Aplicar formatação específica do país
|
||||
return formatPhoneByCountry(phoneNumber, countryCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove formatação do telefone
|
||||
* Exemplo: (11) 98888-7777 -> 11988887777
|
||||
*/
|
||||
export function unformatPhone(value: string): string {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Combina código de país com número de telefone
|
||||
* @param dialCode - Código de discagem (ex: "+55")
|
||||
* @param phoneNumber - Número do telefone (sem código)
|
||||
*/
|
||||
export function combinePhoneWithDialCode(dialCode: string, phoneNumber: string): string {
|
||||
const cleanedPhone = unformatPhone(phoneNumber);
|
||||
const cleanedDialCode = dialCode.replace(/\D/g, "");
|
||||
|
||||
// Remove código de país se já estiver presente
|
||||
if (cleanedPhone.startsWith(cleanedDialCode)) {
|
||||
return `${dialCode}${cleanedPhone.slice(cleanedDialCode.length)}`;
|
||||
}
|
||||
|
||||
return `${dialCode}${cleanedPhone}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula a força da senha
|
||||
* Retorna: 'weak' | 'medium' | 'strong'
|
||||
*/
|
||||
export function checkPasswordStrength(password: string): "weak" | "medium" | "strong" {
|
||||
if (password.length < 8) {
|
||||
return "weak";
|
||||
}
|
||||
|
||||
let strength = 0;
|
||||
|
||||
// Verificar requisitos básicos
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) strength++; // Caracteres especiais
|
||||
|
||||
if (strength <= 3) {
|
||||
return "weak";
|
||||
} else if (strength <= 5) {
|
||||
return "medium";
|
||||
} else {
|
||||
return "strong";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se a senha atende aos requisitos mínimos
|
||||
*/
|
||||
export function validatePasswordRequirements(password: string): {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push("Senha deve ter no mínimo 8 caracteres");
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push("Senha deve conter pelo menos 1 letra maiúscula");
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push("Senha deve conter pelo menos 1 letra minúscula");
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push("Senha deve conter pelo menos 1 número");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
+231
-6
@@ -18,17 +18,30 @@ export const emailSchema = z
|
||||
})
|
||||
.transform((email) => email.trim().toLowerCase());
|
||||
|
||||
// Schema para telefone brasileiro
|
||||
// Schema para telefone (aceita brasileiro e internacional)
|
||||
export const phoneSchema = z
|
||||
.string()
|
||||
.min(1, "Telefone é obrigatório")
|
||||
.refine((phone) => {
|
||||
// Remove todos os caracteres não numéricos
|
||||
// Remove todos os caracteres não numéricos (incluindo +)
|
||||
// Aceita formato internacional com código de país (ex: +55119988887777)
|
||||
const cleaned = phone.replace(/[^\d]/g, "");
|
||||
// Deve ter 10 ou 11 dígitos (telefone fixo ou celular com DDD)
|
||||
return cleaned.length >= 10 && cleaned.length <= 11;
|
||||
}, "Telefone deve ter 10 ou 11 dígitos (incluindo DDD)")
|
||||
.transform((phone) => phone.replace(/[^\d]/g, ""));
|
||||
// Aceita telefones de 8 a 15 dígitos (padrão internacional E.164)
|
||||
// O código de país pode ter 1-3 dígitos, então o número total pode ser maior
|
||||
// Mas para validação, aceitamos de 10 a 16 dígitos (código + número)
|
||||
return cleaned.length >= 10 && cleaned.length <= 16;
|
||||
}, "Telefone deve ter entre 10 e 16 dígitos (incluindo código de país)")
|
||||
.transform((phone) => {
|
||||
// Remove todos os caracteres não numéricos, mantendo apenas dígitos
|
||||
// O formato final será: código de país + número (ex: 55119988887777)
|
||||
return phone.replace(/[^\d]/g, "");
|
||||
});
|
||||
|
||||
// Schema para telefone com código de país (para registro)
|
||||
export const phoneWithCountrySchema = z.object({
|
||||
dialCode: z.string().min(1, "Código de país é obrigatório"),
|
||||
phoneNumber: phoneSchema,
|
||||
});
|
||||
|
||||
// Schema para CPF/CNPJ
|
||||
export const documentSchema = z
|
||||
@@ -136,7 +149,219 @@ export const verifyBillingSchema = z.object({
|
||||
billingId: z.string().min(1, "ID da cobrança é obrigatório"),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// SCHEMAS DE AUTENTICAÇÃO E USUÁRIOS
|
||||
// ============================================
|
||||
|
||||
// Schema para username (alfanumérico + underscore)
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.min(3, "Username deve ter no mínimo 3 caracteres")
|
||||
.max(50, "Username deve ter no máximo 50 caracteres")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_]+$/,
|
||||
"Username deve conter apenas letras, números e underscore"
|
||||
)
|
||||
.transform((username) => username.toLowerCase().trim());
|
||||
|
||||
// Schema para senha (complexidade mínima)
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "Senha deve ter no mínimo 8 caracteres")
|
||||
.refine(
|
||||
(password) => {
|
||||
// Pelo menos 1 maiúscula
|
||||
if (!/[A-Z]/.test(password)) return false;
|
||||
// Pelo menos 1 minúscula
|
||||
if (!/[a-z]/.test(password)) return false;
|
||||
// Pelo menos 1 número
|
||||
if (!/[0-9]/.test(password)) return false;
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Senha deve conter pelo menos 1 letra maiúscula, 1 letra minúscula e 1 número",
|
||||
}
|
||||
);
|
||||
|
||||
// Schema para servidor/personagem no registro
|
||||
export const registerServerCharacterSchema = z.object({
|
||||
serverName: z
|
||||
.string()
|
||||
.min(1, "Nome do servidor é obrigatório")
|
||||
.max(50, "Nome do servidor deve ter no máximo 50 caracteres")
|
||||
.transform((name) => name.trim()),
|
||||
characterName: z
|
||||
.string()
|
||||
.min(3, "Nome do personagem deve ter no mínimo 3 caracteres")
|
||||
.max(100, "Nome do personagem deve ter no máximo 100 caracteres")
|
||||
.transform((name) => name.trim()),
|
||||
isMain: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
// Schema para registro de usuário
|
||||
export const registerSchema = z.object({
|
||||
username: usernameSchema,
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
firstName: nameSchema,
|
||||
lastName: nameSchema,
|
||||
phone: phoneSchema.optional(),
|
||||
servers: z
|
||||
.array(registerServerCharacterSchema)
|
||||
.min(1, "Selecione pelo menos um servidor"),
|
||||
});
|
||||
|
||||
// Schema para login (aceita email ou username)
|
||||
export const loginSchema = z.object({
|
||||
emailOrUsername: z
|
||||
.string()
|
||||
.min(1, "Email ou username é obrigatório")
|
||||
.transform((val) => val.trim().toLowerCase()),
|
||||
password: z.string().min(1, "Senha é obrigatória"),
|
||||
});
|
||||
|
||||
// Schema para atualização de perfil
|
||||
export const updateUserSchema = z.object({
|
||||
firstName: nameSchema.optional(),
|
||||
lastName: nameSchema.optional(),
|
||||
phone: phoneSchema.optional(),
|
||||
whatsappNotifications: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema para personagem
|
||||
export const characterSchema = z.object({
|
||||
characterName: z
|
||||
.string()
|
||||
.min(3, "Nome do personagem deve ter no mínimo 3 caracteres")
|
||||
.max(100, "Nome do personagem deve ter no máximo 100 caracteres")
|
||||
.transform((name) => name.trim()),
|
||||
serverName: z
|
||||
.string()
|
||||
.min(1, "Nome do servidor é obrigatório")
|
||||
.max(50, "Nome do servidor deve ter no máximo 50 caracteres")
|
||||
.transform((name) => name.trim()),
|
||||
isMain: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// SCHEMAS DE MARKETPLACE (LISTINGS E BIDS)
|
||||
// ============================================
|
||||
|
||||
// Schema para criar anúncio (listing)
|
||||
export const createListingSchema = z.object({
|
||||
item_id: z.number().int().positive("ID do item deve ser positivo"),
|
||||
item_name: z
|
||||
.string()
|
||||
.min(1, "Nome do item é obrigatório")
|
||||
.max(255, "Nome do item deve ter no máximo 255 caracteres")
|
||||
.transform((name) => name.trim()),
|
||||
quantity: z
|
||||
.number()
|
||||
.int()
|
||||
.positive("Quantidade deve ser maior que zero")
|
||||
.default(1),
|
||||
server_name: z
|
||||
.string()
|
||||
.min(1, "Nome do servidor é obrigatório")
|
||||
.max(50, "Nome do servidor deve ter no máximo 50 caracteres")
|
||||
.transform((name) => name.trim()),
|
||||
price_pix: z
|
||||
.number()
|
||||
.positive("Preço PIX deve ser positivo")
|
||||
.optional()
|
||||
.nullable(),
|
||||
price_tibia_coins: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, "Preço em Tibia Coins deve ser não negativo")
|
||||
.optional()
|
||||
.nullable(),
|
||||
price_gold_coins: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, "Preço em Gold Coins deve ser não negativo")
|
||||
.optional()
|
||||
.nullable(),
|
||||
is_auction: z.boolean().optional().default(false),
|
||||
auction_ends_at: z
|
||||
.string()
|
||||
.datetime("Data de término do leilão inválida")
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((val) => (val ? new Date(val) : null)),
|
||||
description: z
|
||||
.string()
|
||||
.max(5000, "Descrição deve ter no máximo 5000 caracteres")
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((val) => val?.trim() || null),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Pelo menos um preço deve ser fornecido
|
||||
return (
|
||||
data.price_pix !== null ||
|
||||
data.price_tibia_coins !== null ||
|
||||
data.price_gold_coins !== null
|
||||
);
|
||||
},
|
||||
{
|
||||
message: "Pelo menos um método de pagamento (PIX, Tibia Coins ou Gold Coins) deve ser especificado",
|
||||
path: ["price_pix"],
|
||||
}
|
||||
).refine(
|
||||
(data) => {
|
||||
// Se for leilão, deve ter data de término
|
||||
if (data.is_auction && !data.auction_ends_at) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Data de término do leilão é obrigatória quando is_auction é true",
|
||||
path: ["auction_ends_at"],
|
||||
}
|
||||
);
|
||||
|
||||
// Schema para atualizar anúncio
|
||||
export const updateListingSchema = z.object({
|
||||
quantity: z.number().int().positive().optional(),
|
||||
price_pix: z.number().positive().optional().nullable(),
|
||||
price_tibia_coins: z.number().int().min(0).optional().nullable(),
|
||||
price_gold_coins: z.number().int().min(0).optional().nullable(),
|
||||
description: z.string().max(5000).optional().nullable(),
|
||||
listing_status: z
|
||||
.enum(["active", "sold", "cancelled", "in_escrow", "expired"])
|
||||
.optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Se atualizando preços, pelo menos um deve ser fornecido ou manter existentes
|
||||
const hasPrice =
|
||||
data.price_pix !== undefined ||
|
||||
data.price_tibia_coins !== undefined ||
|
||||
data.price_gold_coins !== undefined;
|
||||
return true; // Permite atualizar sem especificar preços (mantém existentes)
|
||||
}
|
||||
);
|
||||
|
||||
// Schema para criar lance (bid)
|
||||
export const bidSchema = z.object({
|
||||
listing_id: z.number().int().positive("ID do anúncio deve ser positivo"),
|
||||
bid_amount: z.number().positive("Valor do lance deve ser positivo"),
|
||||
bid_type: z
|
||||
.enum(["pix", "tibia_coins", "gold_coins"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
// Type exports para uso no código
|
||||
export type CreateDonationInput = z.infer<typeof createDonationSchema>;
|
||||
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
|
||||
export type VerifyBillingInput = z.infer<typeof verifyBillingSchema>;
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
export type CharacterInput = z.infer<typeof characterSchema>;
|
||||
export type CreateListingInput = z.infer<typeof createListingSchema>;
|
||||
export type UpdateListingInput = z.infer<typeof updateListingSchema>;
|
||||
export type BidInput = z.infer<typeof bidSchema>;
|
||||
@@ -329,5 +329,77 @@
|
||||
"share": "Share with friends",
|
||||
"back": "Back to home"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Sign In",
|
||||
"description": "Sign in to access your account",
|
||||
"emailOrUsername": "Email or Username",
|
||||
"emailOrUsernamePlaceholder": "[email protected] or username",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"loginButton": "Sign In",
|
||||
"loggingIn": "Signing in...",
|
||||
"invalidCredentials": "Invalid credentials"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"description": "Register to start trading",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "your_username",
|
||||
"usernameHint": "3-50 characters, letters, numbers and _",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"passwordHint": "Min. 8 characters, 1 uppercase, 1 lowercase, 1 number",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"confirmPasswordPlaceholder": "••••••••",
|
||||
"firstName": "First Name",
|
||||
"firstNamePlaceholder": "Your first name",
|
||||
"lastName": "Last Name",
|
||||
"lastNamePlaceholder": "Your last name",
|
||||
"phone": "Phone (optional)",
|
||||
"phonePlaceholder": "(11) 98888-7777",
|
||||
"phoneHint": "Select country and enter phone number",
|
||||
"searchCountry": "Search country...",
|
||||
"servers": "Servers",
|
||||
"serversDescription": "Select the servers where you play",
|
||||
"selectServer": "Select Server",
|
||||
"characterName": "Character Name",
|
||||
"characterNamePlaceholder": "Your character name",
|
||||
"characterNameHint": "Min. 3 characters",
|
||||
"addServer": "Add Server",
|
||||
"removeServer": "Remove",
|
||||
"mainCharacter": "Main Character",
|
||||
"atLeastOneServer": "Select at least one server",
|
||||
"characterRequired": "Character name is required",
|
||||
"submit": "Create Account",
|
||||
"submitting": "Creating account...",
|
||||
"success": "Account created successfully!",
|
||||
"errors": {
|
||||
"emailExists": "This email is already registered",
|
||||
"usernameExists": "This username is already in use",
|
||||
"characterExists": "This character is already registered on this server",
|
||||
"serverNotFound": "Server not found or inactive",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"weakPassword": "Password too weak",
|
||||
"invalidEmail": "Invalid email",
|
||||
"invalidPhone": "Invalid phone",
|
||||
"noServers": "Select at least one server",
|
||||
"characterNameRequired": "Character name is required for all selected servers"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"weak": "Weak",
|
||||
"medium": "Medium",
|
||||
"strong": "Strong"
|
||||
},
|
||||
"passwordRequirements": {
|
||||
"minLength": "Minimum 8 characters",
|
||||
"uppercase": "1 uppercase letter",
|
||||
"lowercase": "1 lowercase letter",
|
||||
"number": "1 number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,5 +329,77 @@
|
||||
"share": "Compartilhe com amigos",
|
||||
"back": "Voltar ao início"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Entrar",
|
||||
"description": "Faça login para acessar sua conta",
|
||||
"emailOrUsername": "Email ou Username",
|
||||
"emailOrUsernamePlaceholder": "[email protected] ou username",
|
||||
"password": "Senha",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"loginButton": "Entrar",
|
||||
"loggingIn": "Entrando...",
|
||||
"invalidCredentials": "Credenciais inválidas"
|
||||
},
|
||||
"register": {
|
||||
"title": "Criar Conta",
|
||||
"description": "Registre-se para começar a negociar",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "seu_username",
|
||||
"usernameHint": "3-50 caracteres, letras, números e _",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"password": "Senha",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"passwordHint": "Mín. 8 caracteres, 1 maiúscula, 1 minúscula, 1 número",
|
||||
"confirmPassword": "Confirmar Senha",
|
||||
"confirmPasswordPlaceholder": "••••••••",
|
||||
"firstName": "Nome",
|
||||
"firstNamePlaceholder": "Seu nome",
|
||||
"lastName": "Sobrenome",
|
||||
"lastNamePlaceholder": "Seu sobrenome",
|
||||
"phone": "Telefone (opcional)",
|
||||
"phonePlaceholder": "(11) 98888-7777",
|
||||
"phoneHint": "Selecione o país e informe o telefone",
|
||||
"searchCountry": "Buscar país...",
|
||||
"servers": "Servidores",
|
||||
"serversDescription": "Selecione os servidores onde você joga",
|
||||
"selectServer": "Selecionar Servidor",
|
||||
"characterName": "Nome do Personagem",
|
||||
"characterNamePlaceholder": "Nome do seu personagem",
|
||||
"characterNameHint": "Mín. 3 caracteres",
|
||||
"addServer": "Adicionar Servidor",
|
||||
"removeServer": "Remover",
|
||||
"mainCharacter": "Personagem Principal",
|
||||
"atLeastOneServer": "Selecione pelo menos um servidor",
|
||||
"characterRequired": "Nome do personagem é obrigatório",
|
||||
"submit": "Criar Conta",
|
||||
"submitting": "Criando conta...",
|
||||
"success": "Conta criada com sucesso!",
|
||||
"errors": {
|
||||
"emailExists": "Este email já está cadastrado",
|
||||
"usernameExists": "Este username já está em uso",
|
||||
"characterExists": "Este personagem já está cadastrado neste servidor",
|
||||
"serverNotFound": "Servidor não encontrado ou inativo",
|
||||
"passwordMismatch": "As senhas não coincidem",
|
||||
"weakPassword": "Senha muito fraca",
|
||||
"invalidEmail": "Email inválido",
|
||||
"invalidPhone": "Telefone inválido",
|
||||
"noServers": "Selecione pelo menos um servidor",
|
||||
"characterNameRequired": "Nome do personagem é obrigatório para todos os servidores selecionados"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"weak": "Fraca",
|
||||
"medium": "Média",
|
||||
"strong": "Forte"
|
||||
},
|
||||
"passwordRequirements": {
|
||||
"minLength": "Mínimo 8 caracteres",
|
||||
"uppercase": "1 letra maiúscula",
|
||||
"lowercase": "1 letra minúscula",
|
||||
"number": "1 número"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -7,7 +7,10 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write"
|
||||
"format": "biome format --write",
|
||||
"db:migrate": "tsx lib/db/migrations/run-migrations.ts",
|
||||
"db:seed": "tsx lib/db/seeds/servers-seed.ts",
|
||||
"db:test": "tsx lib/db/test-connection.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"tibia marketplace",
|
||||
@@ -26,25 +29,33 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/pg": "^8.15.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^0.6.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "15.5.0",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"recharts": "^3.3.0",
|
||||
"redis": "^5.9.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"validator": "^13.15.20",
|
||||
@@ -58,7 +69,9 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/validator": "^13.15.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
Generated
+667
@@ -23,6 +23,9 @@ importers:
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2([email protected])
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-navigation-menu':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -35,9 +38,21 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@types/bcrypt':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
'@types/pg':
|
||||
specifier: ^8.15.6
|
||||
version: 8.15.6
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
better-sqlite3:
|
||||
specifier: ^12.4.1
|
||||
version: 12.4.1
|
||||
@@ -53,6 +68,9 @@ importers:
|
||||
cobe:
|
||||
specifier: ^0.6.5
|
||||
version: 0.6.5
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
lucide-react:
|
||||
specifier: ^0.540.0
|
||||
version: 0.540.0([email protected])
|
||||
@@ -68,6 +86,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.16.3
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -80,6 +101,9 @@ importers:
|
||||
recharts:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||
redis:
|
||||
specifier: ^5.9.0
|
||||
version: 5.9.0
|
||||
sanitize-html:
|
||||
specifier: ^2.17.0
|
||||
version: 2.17.0
|
||||
@@ -114,9 +138,15 @@ importers:
|
||||
'@types/validator':
|
||||
specifier: ^13.15.3
|
||||
version: 13.15.3
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.15
|
||||
tsx:
|
||||
specifier: ^4.20.6
|
||||
version: 4.20.6
|
||||
tw-animate-css:
|
||||
specifier: ^1.3.7
|
||||
version: 1.4.0
|
||||
@@ -186,6 +216,162 @@ packages:
|
||||
'@emnapi/[email protected]':
|
||||
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@floating-ui/[email protected]':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
@@ -582,6 +768,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||
peerDependencies:
|
||||
@@ -805,6 +1004,34 @@ packages:
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@redis/[email protected]':
|
||||
resolution: {integrity: sha512-W9D8yfKTWl4tP8lkC3MRYkMz4OfbuzE/W8iObe0jFgoRmgMfkBV+Vj38gvIqZPImtY0WB34YZkX3amYuQebvRQ==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@redis/client': ^5.9.0
|
||||
|
||||
'@redis/[email protected]':
|
||||
resolution: {integrity: sha512-EI0Ti5pojD2p7TmcS7RRa+AJVahdQvP/urpcSbK/K9Rlk6+dwMJTQ354pCNGCwfke8x4yKr5+iH85wcERSkwLQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@redis/[email protected]':
|
||||
resolution: {integrity: sha512-Bm2jjLYaXdUWPb9RaEywxnjmzw7dWKDZI4MS79mTWPV16R982jVWBj6lY2ZGelJbwxHtEVg4/FSVgYDkuO/MxA==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@redis/client': ^5.9.0
|
||||
|
||||
'@redis/[email protected]':
|
||||
resolution: {integrity: sha512-jdk2csmJ29DlpvCIb2ySjix2co14/0iwIT3C0I+7ZaToXgPbgBMB+zfEilSuncI2F9JcVxHki0YtLA0xX3VdpA==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@redis/client': ^5.9.0
|
||||
|
||||
'@redis/[email protected]':
|
||||
resolution: {integrity: sha512-W6ILxcyOqhnI7ELKjJXOktIg3w4+aBHugDbVpgVLPZ+YDjObis1M0v7ZzwlpXhlpwsfePfipeSK+KWNuymk52w==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@redis/client': ^5.9.0
|
||||
|
||||
'@reduxjs/[email protected]':
|
||||
resolution: {integrity: sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==}
|
||||
peerDependencies:
|
||||
@@ -916,6 +1143,9 @@ packages:
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||
|
||||
@@ -946,9 +1176,18 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
|
||||
peerDependencies:
|
||||
@@ -973,6 +1212,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x}
|
||||
@@ -983,6 +1226,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
@@ -1005,6 +1251,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==}
|
||||
|
||||
@@ -1093,6 +1343,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
@@ -1107,6 +1364,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1138,10 +1400,18 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
@@ -1181,6 +1451,16 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1251,6 +1531,27 @@ packages:
|
||||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
@@ -1293,6 +1594,9 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -1346,6 +1650,14 @@ packages:
|
||||
resolution: {integrity: sha512-Pr/5KdBQGG8TirdkS0qN3B+f3eo8zTOfZQWAxHoJqopMz2/uvRnG+S4fWu/6AZxKei2CP2p/psdQ5HFC2Ap5BA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1356,6 +1668,40 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
|
||||
|
||||
@@ -1370,6 +1716,22 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1462,6 +1824,10 @@ packages:
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-E8dQVLSyH6UE/C9darFuwq4usOPrqfZ1864kI4RFbr5Oj9ioB9qPF0oJMwX7s8mf6sPYrz84x/Dx1PGF3/0EaQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
@@ -1473,6 +1839,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
@@ -1501,6 +1870,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -1544,6 +1917,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
@@ -1601,6 +1979,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||
|
||||
@@ -1648,6 +2030,84 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@floating-ui/[email protected]':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
@@ -1994,6 +2454,15 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -2210,6 +2679,26 @@ snapshots:
|
||||
|
||||
'@radix-ui/[email protected]': {}
|
||||
|
||||
'@redis/[email protected](@redis/[email protected])':
|
||||
dependencies:
|
||||
'@redis/client': 5.9.0
|
||||
|
||||
'@redis/[email protected]':
|
||||
dependencies:
|
||||
cluster-key-slot: 1.1.2
|
||||
|
||||
'@redis/[email protected](@redis/[email protected])':
|
||||
dependencies:
|
||||
'@redis/client': 5.9.0
|
||||
|
||||
'@redis/[email protected](@redis/[email protected])':
|
||||
dependencies:
|
||||
'@redis/client': 5.9.0
|
||||
|
||||
'@redis/[email protected](@redis/[email protected])':
|
||||
dependencies:
|
||||
'@redis/client': 5.9.0
|
||||
|
||||
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected])([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
@@ -2301,6 +2790,10 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.1.15
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/node': 20.19.23
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]': {}
|
||||
@@ -2327,10 +2820,23 @@ snapshots:
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 20.19.23
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/node': 20.19.23
|
||||
pg-protocol: 1.10.3
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/[email protected](@types/[email protected])':
|
||||
dependencies:
|
||||
'@types/react': 19.2.2
|
||||
@@ -2353,6 +2859,11 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
node-addon-api: 8.5.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
@@ -2368,6 +2879,8 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -2387,6 +2900,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
phenomenon: 1.6.0
|
||||
@@ -2465,6 +2980,12 @@ snapshots:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -2478,6 +2999,35 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
'@esbuild/android-arm': 0.25.12
|
||||
'@esbuild/android-arm64': 0.25.12
|
||||
'@esbuild/android-x64': 0.25.12
|
||||
'@esbuild/darwin-arm64': 0.25.12
|
||||
'@esbuild/darwin-x64': 0.25.12
|
||||
'@esbuild/freebsd-arm64': 0.25.12
|
||||
'@esbuild/freebsd-x64': 0.25.12
|
||||
'@esbuild/linux-arm': 0.25.12
|
||||
'@esbuild/linux-arm64': 0.25.12
|
||||
'@esbuild/linux-ia32': 0.25.12
|
||||
'@esbuild/linux-loong64': 0.25.12
|
||||
'@esbuild/linux-mips64el': 0.25.12
|
||||
'@esbuild/linux-ppc64': 0.25.12
|
||||
'@esbuild/linux-riscv64': 0.25.12
|
||||
'@esbuild/linux-s390x': 0.25.12
|
||||
'@esbuild/linux-x64': 0.25.12
|
||||
'@esbuild/netbsd-arm64': 0.25.12
|
||||
'@esbuild/netbsd-x64': 0.25.12
|
||||
'@esbuild/openbsd-arm64': 0.25.12
|
||||
'@esbuild/openbsd-x64': 0.25.12
|
||||
'@esbuild/openharmony-arm64': 0.25.12
|
||||
'@esbuild/sunos-x64': 0.25.12
|
||||
'@esbuild/win32-arm64': 0.25.12
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2497,8 +3047,15 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2533,6 +3090,30 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
jws: 3.2.2
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
jwa: 1.4.2
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -2582,6 +3163,20 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.30.2
|
||||
lightningcss-win32-x64-msvc: 1.30.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
@@ -2614,6 +3209,8 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2662,6 +3259,10 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2670,6 +3271,41 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
pg: 8.16.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.0
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
pg-connection-string: 2.9.1
|
||||
pg-pool: 3.10.1([email protected])
|
||||
pg-protocol: 1.10.3
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.2.7
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2686,6 +3322,16 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -2800,6 +3446,14 @@ snapshots:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@redis/bloom': 5.9.0(@redis/[email protected])
|
||||
'@redis/client': 5.9.0
|
||||
'@redis/json': 5.9.0(@redis/[email protected])
|
||||
'@redis/search': 5.9.0(@redis/[email protected])
|
||||
'@redis/time-series': 5.9.0(@redis/[email protected])
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
@@ -2808,6 +3462,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2863,6 +3519,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -2899,6 +3557,13 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
get-tsconfig: 4.13.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -2958,4 +3623,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -0,0 +1,4 @@
|
||||
planeje e vamos fazer a tela de market deverá ter um botão que permitirá o usuario adicionar um anuncio para vender.
|
||||
ele poderá selecionar qualquer um dos itens que temos na library.
|
||||
e poderá colocar preço mínimo desejado.
|
||||
vamos fazer uma telemetria completa de vários dados de venda e compra, datas, horários, quantos cliques o anuncio do usuario teve, quanto tempo o usuario ficou no anuncio, e etc para vender esses dados em um plano no futuro.
|
||||
Reference in new issue
Block a user