From 3446509c8b44f7699fb0953f2d67d60f899b4831 Mon Sep 17 00:00:00 2001 From: XDuke Date: Tue, 29 Jul 2025 13:28:21 -0300 Subject: [PATCH 1/8] feat(upload): Implement file size validation and streamline upload process for multiple files --- src/app/dashboard/app/[identifier]/page.tsx | 131 ++++++-------------- 1 file changed, 38 insertions(+), 93 deletions(-) diff --git a/src/app/dashboard/app/[identifier]/page.tsx b/src/app/dashboard/app/[identifier]/page.tsx index 5952b07..dfb7829 100644 --- a/src/app/dashboard/app/[identifier]/page.tsx +++ b/src/app/dashboard/app/[identifier]/page.tsx @@ -581,111 +581,56 @@ export default function ServerDetailsPage() { const files = event.target.files; if (!files || !accessKey) return; - try { - setUploadProgress(0); - setFilesError(null); // Clear any previous errors - - // Get upload URL - const urlResult = await getUploadUrl(identifier, accessKey, currentPath); - if (!urlResult.success || !urlResult.data) { - throw new Error(typeof urlResult.error === 'string' ? urlResult.error : "Erro ao obter URL de upload"); + // Checagem de tamanho de arquivo (100MB = 104857600 bytes) + for (let i = 0; i < files.length; i++) { + if (files[i].size > 104857600) { + setFilesError(`O arquivo "${files[i].name}" excede o limite de 100MB.`); + return; } + } - // Upload each file individually with proper progress tracking - for (let i = 0; i < files.length; i++) { - const file = files[i]; - console.log(`Starting upload for ${file.name} (${i + 1}/${files.length})`); - - // Update progress to show current file - const baseProgress = (i / files.length) * 100; - setUploadProgress(baseProgress); + try { + setUploadProgress(0); + setFilesError(null); + + // Monta o FormData para múltiplos arquivos + const formData = new FormData(); + formData.append('server_identifier', identifier); + formData.append('directory', currentPath || '/'); + for (let i = 0; i < files.length; i++) { + formData.append('files[]', files[i]); + } - try { - const formData = new FormData(); - formData.append('files', file); - - const response = await fetch(urlResult.data.url, { - method: 'POST', - mode: 'cors', - credentials: 'omit', - body: formData, - }); - if (response.ok) { - console.log(`✅ Upload successful for ${file.name}`); - // Update progress to completion for this file - setUploadProgress(((i + 1) / files.length) * 100); - } else { - // Handle HTTP errors - let httpErrorMessage = `Erro HTTP ${response.status} ao fazer upload de ${file.name}`; - try { - const errorData = await response.json(); - httpErrorMessage = typeof errorData.message === 'string' ? errorData.message : httpErrorMessage; - } catch { - httpErrorMessage = `HTTP ${response.status}: ${response.statusText}`; - } - throw new Error(httpErrorMessage); - } + // Usa a URL da API do .env + const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; + const uploadUrl = apiUrl.replace(/\/$/, '') + '/v1/files/upload'; + const response = await fetch(uploadUrl, { + method: 'POST', + body: formData, + }); - } catch (fetchError: any) { - // Check if this is a network error that indicates the upload might have succeeded - if (fetchError.message && - (fetchError.message.includes('NetworkError') || - fetchError.message.includes('Failed to fetch') || - fetchError.message.includes('CORS'))) { - - console.log(`⚠️ Network/CORS error for ${file.name} - checking if upload was successful...`); - - // Wait a moment and refresh file list to check if file was uploaded - await new Promise(resolve => setTimeout(resolve, 2000)); - - try { - // Get fresh file list from server - const freshResult = await getServerFiles(identifier, accessKey, currentPath); - if (freshResult.success && freshResult.data) { - // Check if the file now exists in the fresh server list - const fileExists = freshResult.data.some(serverFile => serverFile.name === file.name); - - if (fileExists) { - console.log(`✅ File ${file.name} was actually uploaded successfully despite network error`); - // Update local state with fresh data - setServerFiles(freshResult.data); - setUploadProgress(((i + 1) / files.length) * 100); - continue; // Move to next file - } else { - console.error(`❌ Upload failed for ${file.name}:`, fetchError.message); - console.log(`❌ File ${file.name} was not found on server after network error`); - } - } - } catch (checkError) { - console.error(`❌ Upload failed for ${file.name}:`, fetchError.message); - console.log(`❌ Failed to verify upload for ${file.name}:`, checkError); - } - - // If we get here, the upload truly failed or we couldn't verify it - console.log(`🚨 Treating as genuine upload failure for ${file.name}`); - throw new Error(`Erro de rede ao fazer upload de ${file.name}. Verifique sua conexão e tente novamente.`); - } else { - // Other types of errors - log immediately - console.error(`❌ Upload failed for ${file.name}:`, fetchError.message); - throw new Error(`Erro ao fazer upload de ${file.name}: ${fetchError.message}`); - } - } + if (response.ok) { + setUploadProgress(100); + await fetchFiles(currentPath); + setUploadProgress(null); + console.log('🎉 Upload concluído com sucesso'); + } else { + let errorMessage = `Erro HTTP ${response.status} ao fazer upload.`; + try { + const errorData = await response.json(); + errorMessage = errorData?.error || errorMessage; + } catch {} + setFilesError(errorMessage); + setUploadProgress(null); } - - // Final refresh and cleanup - await fetchFiles(currentPath); - setUploadProgress(null); - - console.log('🎉 All uploads completed successfully'); - } catch (err: any) { const errorMessage = typeof err === 'string' ? err : (err?.message || "Erro ao fazer upload"); setFilesError(errorMessage); setUploadProgress(null); console.error('Upload error:', err); } - + // Reset input if (event && event.target) { (event.target as HTMLInputElement).value = ''; From a49306cbd494c141d06b3d572b27b9fcaab508a1 Mon Sep 17 00:00:00 2001 From: XDuke Date: Tue, 29 Jul 2025 13:40:17 -0300 Subject: [PATCH 2/8] feat(server-details): Update upload URL to include user-specific server path --- src/app/dashboard/app/[identifier]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/dashboard/app/[identifier]/page.tsx b/src/app/dashboard/app/[identifier]/page.tsx index dfb7829..acb59bb 100644 --- a/src/app/dashboard/app/[identifier]/page.tsx +++ b/src/app/dashboard/app/[identifier]/page.tsx @@ -602,9 +602,9 @@ export default function ServerDetailsPage() { } - // Usa a URL da API do .env + // Usa a URL da API do .env e monta a rota correta const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const uploadUrl = apiUrl.replace(/\/$/, '') + '/v1/files/upload'; + const uploadUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload`; const response = await fetch(uploadUrl, { method: 'POST', body: formData, From a793d83e546e6fafa3c1145277c8f8fd47a277f5 Mon Sep 17 00:00:00 2001 From: XDuke Date: Tue, 29 Jul 2025 13:48:08 -0300 Subject: [PATCH 3/8] feat(upload): Refactor upload process to retrieve upload URL dynamically and include directory in request --- src/app/dashboard/app/[identifier]/page.tsx | 46 ++++++++++++++------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/app/dashboard/app/[identifier]/page.tsx b/src/app/dashboard/app/[identifier]/page.tsx index acb59bb..2bb4582 100644 --- a/src/app/dashboard/app/[identifier]/page.tsx +++ b/src/app/dashboard/app/[identifier]/page.tsx @@ -589,36 +589,52 @@ export default function ServerDetailsPage() { } } + try { setUploadProgress(0); setFilesError(null); - // Monta o FormData para múltiplos arquivos - const formData = new FormData(); - formData.append('server_identifier', identifier); - formData.append('directory', currentPath || '/'); - for (let i = 0; i < files.length; i++) { - formData.append('files[]', files[i]); - } - - - // Usa a URL da API do .env e monta a rota correta + // 1. GET para obter a URL de upload const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const uploadUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload`; - const response = await fetch(uploadUrl, { + const getUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload&directory=${encodeURIComponent(currentPath || '/')}`; + const getResp = await fetch(getUrl, { method: 'GET' }); + if (!getResp.ok) { + let errorMessage = `Erro HTTP ${getResp.status} ao obter URL de upload.`; + try { + const errorData = await getResp.json(); + errorMessage = errorData?.error || errorMessage; + } catch {} + setFilesError(errorMessage); + setUploadProgress(null); + return; + } + const getData = await getResp.json(); + if (!getData.url) { + setFilesError('URL de upload não retornada pela API.'); + setUploadProgress(null); + return; + } + + // 2. POST para a URL retornada, incluindo 'directory' no body + const formData = new FormData(); + formData.append('directory', currentPath || '/'); + for (let i = 0; i < files.length; i++) { + formData.append('files[]', files[i]); + } + const uploadResp = await fetch(getData.url, { method: 'POST', body: formData, }); - if (response.ok) { + if (uploadResp.ok) { setUploadProgress(100); await fetchFiles(currentPath); setUploadProgress(null); console.log('🎉 Upload concluído com sucesso'); } else { - let errorMessage = `Erro HTTP ${response.status} ao fazer upload.`; + let errorMessage = `Erro HTTP ${uploadResp.status} ao fazer upload.`; try { - const errorData = await response.json(); + const errorData = await uploadResp.json(); errorMessage = errorData?.error || errorMessage; } catch {} setFilesError(errorMessage); From 652893c72115c0fa64d8bf6d6c490a61f610a305 Mon Sep 17 00:00:00 2001 From: XDuke Date: Tue, 29 Jul 2025 13:52:20 -0300 Subject: [PATCH 4/8] feat(upload): Add authorization header to upload URL fetch requests --- src/app/dashboard/app/[identifier]/page.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/dashboard/app/[identifier]/page.tsx b/src/app/dashboard/app/[identifier]/page.tsx index 2bb4582..a0d5e84 100644 --- a/src/app/dashboard/app/[identifier]/page.tsx +++ b/src/app/dashboard/app/[identifier]/page.tsx @@ -590,6 +590,7 @@ export default function ServerDetailsPage() { } + try { setUploadProgress(0); setFilesError(null); @@ -597,7 +598,12 @@ export default function ServerDetailsPage() { // 1. GET para obter a URL de upload const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; const getUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload&directory=${encodeURIComponent(currentPath || '/')}`; - const getResp = await fetch(getUrl, { method: 'GET' }); + const getResp = await fetch(getUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessKey}` + } + }); if (!getResp.ok) { let errorMessage = `Erro HTTP ${getResp.status} ao obter URL de upload.`; try { @@ -623,6 +629,9 @@ export default function ServerDetailsPage() { } const uploadResp = await fetch(getData.url, { method: 'POST', + headers: { + 'Authorization': `Bearer ${accessKey}` + }, body: formData, }); From ef486c8438db57051c07e31886d1942c162d55d0 Mon Sep 17 00:00:00 2001 From: XDuke Date: Tue, 29 Jul 2025 13:56:36 -0300 Subject: [PATCH 5/8] feat(upload): Change upload URL retrieval to POST method with directory and files in request body --- src/app/dashboard/app/[identifier]/page.tsx | 42 +++++++++++---------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/app/dashboard/app/[identifier]/page.tsx b/src/app/dashboard/app/[identifier]/page.tsx index a0d5e84..e2e52fc 100644 --- a/src/app/dashboard/app/[identifier]/page.tsx +++ b/src/app/dashboard/app/[identifier]/page.tsx @@ -581,6 +581,7 @@ export default function ServerDetailsPage() { const files = event.target.files; if (!files || !accessKey) return; + // Checagem de tamanho de arquivo (100MB = 104857600 bytes) for (let i = 0; i < files.length; i++) { if (files[i].size > 104857600) { @@ -589,49 +590,50 @@ export default function ServerDetailsPage() { } } - - try { setUploadProgress(0); setFilesError(null); - // 1. GET para obter a URL de upload + // 1. POST para obter a URL de upload, enviando directory e files no body JSON const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const getUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload&directory=${encodeURIComponent(currentPath || '/')}`; - const getResp = await fetch(getUrl, { - method: 'GET', + const postUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload`; + const fileNames = Array.from(files).map(f => f.name); + const postBody = JSON.stringify({ + directory: currentPath || '/', + files: fileNames + }); + const postResp = await fetch(postUrl, { + method: 'POST', headers: { - 'Authorization': `Bearer ${accessKey}` - } + 'Authorization': `Bearer ${accessKey}`, + 'Content-Type': 'application/json' + }, + body: postBody }); - if (!getResp.ok) { - let errorMessage = `Erro HTTP ${getResp.status} ao obter URL de upload.`; + if (!postResp.ok) { + let errorMessage = `Erro HTTP ${postResp.status} ao obter URL de upload.`; try { - const errorData = await getResp.json(); + const errorData = await postResp.json(); errorMessage = errorData?.error || errorMessage; } catch {} setFilesError(errorMessage); setUploadProgress(null); return; } - const getData = await getResp.json(); - if (!getData.url) { + const postData = await postResp.json(); + if (!postData.url) { setFilesError('URL de upload não retornada pela API.'); setUploadProgress(null); return; } - // 2. POST para a URL retornada, incluindo 'directory' no body + // 2. Upload real dos arquivos para a URL retornada (provavelmente um presigned URL ou endpoint de upload) const formData = new FormData(); - formData.append('directory', currentPath || '/'); for (let i = 0; i < files.length; i++) { - formData.append('files[]', files[i]); + formData.append('files[]', files[i], files[i].name); } - const uploadResp = await fetch(getData.url, { + const uploadResp = await fetch(postData.url, { method: 'POST', - headers: { - 'Authorization': `Bearer ${accessKey}` - }, body: formData, }); From 921e52a1bee60b7cfbb81c89d4802c252e3bdaf5 Mon Sep 17 00:00:00 2001 From: XDuke Date: Tue, 29 Jul 2025 14:39:20 -0300 Subject: [PATCH 6/8] feat(upload): Enhance file upload process with dynamic URL retrieval and improved error handling --- src/app/dashboard/app/[identifier]/page.tsx | 156 ++++++++++++-------- 1 file changed, 92 insertions(+), 64 deletions(-) diff --git a/src/app/dashboard/app/[identifier]/page.tsx b/src/app/dashboard/app/[identifier]/page.tsx index e2e52fc..bda0019 100644 --- a/src/app/dashboard/app/[identifier]/page.tsx +++ b/src/app/dashboard/app/[identifier]/page.tsx @@ -581,83 +581,111 @@ export default function ServerDetailsPage() { const files = event.target.files; if (!files || !accessKey) return; - - // Checagem de tamanho de arquivo (100MB = 104857600 bytes) - for (let i = 0; i < files.length; i++) { - if (files[i].size > 104857600) { - setFilesError(`O arquivo "${files[i].name}" excede o limite de 100MB.`); - return; - } - } - try { setUploadProgress(0); - setFilesError(null); - - // 1. POST para obter a URL de upload, enviando directory e files no body JSON - const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const postUrl = apiUrl.replace(/\/$/, '') + `/v1/users/me/servers/games/${identifier}/files?action=upload`; - const fileNames = Array.from(files).map(f => f.name); - const postBody = JSON.stringify({ - directory: currentPath || '/', - files: fileNames - }); - const postResp = await fetch(postUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessKey}`, - 'Content-Type': 'application/json' - }, - body: postBody - }); - if (!postResp.ok) { - let errorMessage = `Erro HTTP ${postResp.status} ao obter URL de upload.`; - try { - const errorData = await postResp.json(); - errorMessage = errorData?.error || errorMessage; - } catch {} - setFilesError(errorMessage); - setUploadProgress(null); - return; - } - const postData = await postResp.json(); - if (!postData.url) { - setFilesError('URL de upload não retornada pela API.'); - setUploadProgress(null); - return; + setFilesError(null); // Clear any previous errors + + // Get upload URL + const urlResult = await getUploadUrl(identifier, accessKey, currentPath); + if (!urlResult.success || !urlResult.data) { + throw new Error(typeof urlResult.error === 'string' ? urlResult.error : "Erro ao obter URL de upload"); } - // 2. Upload real dos arquivos para a URL retornada (provavelmente um presigned URL ou endpoint de upload) - const formData = new FormData(); + // Upload each file individually with proper progress tracking for (let i = 0; i < files.length; i++) { - formData.append('files[]', files[i], files[i].name); - } - const uploadResp = await fetch(postData.url, { - method: 'POST', - body: formData, - }); + const file = files[i]; + console.log(`Starting upload for ${file.name} (${i + 1}/${files.length})`); + + // Update progress to show current file + const baseProgress = (i / files.length) * 100; + setUploadProgress(baseProgress); - if (uploadResp.ok) { - setUploadProgress(100); - await fetchFiles(currentPath); - setUploadProgress(null); - console.log('🎉 Upload concluído com sucesso'); - } else { - let errorMessage = `Erro HTTP ${uploadResp.status} ao fazer upload.`; try { - const errorData = await uploadResp.json(); - errorMessage = errorData?.error || errorMessage; - } catch {} - setFilesError(errorMessage); - setUploadProgress(null); + const formData = new FormData(); + formData.append('files', file); + + const response = await fetch(urlResult.data.url, { + method: 'POST', + mode: 'cors', + credentials: 'omit', + body: formData, + }); + + if (response.ok) { + console.log(`✅ Upload successful for ${file.name}`); + // Update progress to completion for this file + setUploadProgress(((i + 1) / files.length) * 100); + } else { + // Handle HTTP errors + let httpErrorMessage = `Erro HTTP ${response.status} ao fazer upload de ${file.name}`; + try { + const errorData = await response.json(); + httpErrorMessage = typeof errorData.message === 'string' ? errorData.message : httpErrorMessage; + } catch { + httpErrorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + throw new Error(httpErrorMessage); + } + + } catch (fetchError: any) { + // Check if this is a network error that indicates the upload might have succeeded + if (fetchError.message && + (fetchError.message.includes('NetworkError') || + fetchError.message.includes('Failed to fetch') || + fetchError.message.includes('CORS'))) { + + console.log(`⚠️ Network/CORS error for ${file.name} - checking if upload was successful...`); + + // Wait a moment and refresh file list to check if file was uploaded + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + // Get fresh file list from server + const freshResult = await getServerFiles(identifier, accessKey, currentPath); + if (freshResult.success && freshResult.data) { + // Check if the file now exists in the fresh server list + const fileExists = freshResult.data.some(serverFile => serverFile.name === file.name); + + if (fileExists) { + console.log(`✅ File ${file.name} was actually uploaded successfully despite network error`); + // Update local state with fresh data + setServerFiles(freshResult.data); + setUploadProgress(((i + 1) / files.length) * 100); + continue; // Move to next file + } else { + console.error(`❌ Upload failed for ${file.name}:`, fetchError.message); + console.log(`❌ File ${file.name} was not found on server after network error`); + } + } + } catch (checkError) { + console.error(`❌ Upload failed for ${file.name}:`, fetchError.message); + console.log(`❌ Failed to verify upload for ${file.name}:`, checkError); + } + + // If we get here, the upload truly failed or we couldn't verify it + console.log(`🚨 Treating as genuine upload failure for ${file.name}`); + throw new Error(`Erro de rede ao fazer upload de ${file.name}. Verifique sua conexão e tente novamente.`); + } else { + // Other types of errors - log immediately + console.error(`❌ Upload failed for ${file.name}:`, fetchError.message); + throw new Error(`Erro ao fazer upload de ${file.name}: ${fetchError.message}`); + } + } } + + // Final refresh and cleanup + await fetchFiles(currentPath); + setUploadProgress(null); + + console.log('🎉 All uploads completed successfully'); + } catch (err: any) { const errorMessage = typeof err === 'string' ? err : (err?.message || "Erro ao fazer upload"); setFilesError(errorMessage); setUploadProgress(null); console.error('Upload error:', err); } - + // Reset input if (event && event.target) { (event.target as HTMLInputElement).value = ''; @@ -1501,4 +1529,4 @@ export default function ServerDetailsPage() { /> ); -} +} \ No newline at end of file From 112242c8a11cf3a5669a6795e3e8a6517cd71a7b Mon Sep 17 00:00:00 2001 From: XDuke Date: Fri, 1 Aug 2025 21:21:49 -0300 Subject: [PATCH 7/8] feat: Update ContactPage to use Discord instead of Telegram for support fix: Enhance Minecraft hosting service description with specific hardware details feat: Add AMD Ryzen 9 7900X specifications to service features across multiple components feat: Improve Services component to include additional features for Minecraft and VPS plans feat: Expand Footer component with new social media links for YouTube and TikTok refactor: Revamp MinecraftPlans component to support annual billing and display pricing dynamically refactor: Update VpsPlans component to enhance pricing display and add promotional tags fix: Refine AuthContext to handle new login methods and improve error handling feat: Implement WebSocket proxy with enhanced error handling and logging feat: Update auth API routes for Discord and Google login with proper redirection and error handling --- .env | 4 +- config.json | 2 +- package.json | 1 + src/app/checkout/[service]/[plan]/page.tsx | 597 ++++++-------------- src/app/lgpd/page.tsx | 4 +- src/app/login/page.tsx | 122 +++- src/app/sac/page.tsx | 10 +- src/app/services/minecraft/page.tsx | 7 +- src/app/services/page.tsx | 2 + src/components/home/Services.tsx | 6 +- src/components/layout/Footer.tsx | 22 +- src/components/minecraft/MinecraftPlans.tsx | 227 ++++++-- src/components/vps/VpsPlans.tsx | 146 +++-- src/contexts/AuthContext.tsx | 32 +- src/pages/api/auth/discord-redirect.ts | 25 + src/pages/api/auth/discord/callback.ts | 40 ++ src/pages/api/auth/google-redirect.ts | 25 + src/pages/api/auth/google/callback.ts | 41 ++ src/pages/api/websocket-proxy.ts | 55 +- src/services/api/auth.ts | 31 +- 20 files changed, 772 insertions(+), 627 deletions(-) create mode 100644 src/pages/api/auth/discord-redirect.ts create mode 100644 src/pages/api/auth/discord/callback.ts create mode 100644 src/pages/api/auth/google-redirect.ts create mode 100644 src/pages/api/auth/google/callback.ts diff --git a/.env b/.env index db4cc05..ac67a9f 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -API_URL=https://system-api.firehosting.com.br -NEXT_PUBLIC_API_URL=https://system-api.firehosting.com.br +API_URL=https://api-dev.firehosting.com.br +NEXT_PUBLIC_API_URL=https://api-dev.firehosting.com.br NEXT_PUBLIC_SOCKET_URL=https://firehosting-socket.squareweb.app NEXT_PUBLIC_CENTRAL_URL=https://central.firehosting.com.br \ No newline at end of file diff --git a/config.json b/config.json index a1d5d09..50772b9 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "api": { - "baseUrl": "https://system-api.firehosting.com.br", + "baseUrl": "https://api-dev.firehosting.com.br", "authKey": "CjYYooDNiVgzWBJPNlYyIUfoxJRtLozDFiTAoHdQuPAnxFEAuK" } } diff --git a/package.json b/package.json index 9ce0d55..bd8f3f3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "next": "^15.3.5", "next-themes": "^0.2.1", "react": "^18.2.0", + "react-countup": "^6.5.3", "react-dom": "^18.2.0", "react-icons": "^4.11.0" }, diff --git a/src/app/checkout/[service]/[plan]/page.tsx b/src/app/checkout/[service]/[plan]/page.tsx index c09d307..917e97c 100644 --- a/src/app/checkout/[service]/[plan]/page.tsx +++ b/src/app/checkout/[service]/[plan]/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useContext, useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useContext, useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams, useParams } from "next/navigation"; import { FaServer, FaMemory, @@ -13,6 +13,7 @@ import { FaTag, FaTimes, FaSpinner, + FaRegClock, } from "react-icons/fa"; import { motion } from "framer-motion"; import config from "../../../../../config.json"; @@ -103,11 +104,45 @@ interface Promotion { notes: string; } -const CheckoutPage = ({ - params, -}: { - params: Promise<{ service: string; plan: string }>; -}) => { +const CountdownTimer = ({ initialMinutes = 20 }: { initialMinutes: number }) => { + const [timeLeft, setTimeLeft] = useState(initialMinutes * 60); + + useEffect(() => { + if (timeLeft <= 0) return; + const intervalId = setInterval(() => { + setTimeLeft(prevTime => prevTime - 1); + }, 1000); + return () => clearInterval(intervalId); + }, [timeLeft]); + + const minutes = Math.floor(timeLeft / 60); + const seconds = timeLeft % 60; + + return ( +
+
+ +

Sua oferta expira em:

+
+

+ {String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')} +

+
+ ); +}; + +const PageLoader = () => ( +
+
+
+

+ Carregando detalhes do seu pedido... +

+
+
+); + +const CheckoutPageComponent = () => { const [plan, setPlan] = useState(null); const [loading, setLoading] = useState(true); const [processingPayment, setProcessingPayment] = useState(false); @@ -116,8 +151,6 @@ const CheckoutPage = ({ const [serverName, setServerName] = useState(""); const [eggId, setEggId] = useState(""); const [osId, setOsId] = useState(""); - - // Estados para cupom de desconto const [couponCode, setCouponCode] = useState(""); const [appliedCoupon, setAppliedCoupon] = useState(null); const [couponError, setCouponError] = useState(null); @@ -125,29 +158,49 @@ const CheckoutPage = ({ const authContext = useContext(AuthContext); const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams<{ service: string; plan: string }>(); - const { service, plan: planName } = React.use(params); + const service = params?.service || ''; + const rawPlanName = params?.plan || ''; + + const planName = decodeURIComponent(rawPlanName); const isMinecraft = service.toLowerCase() === "minecraft"; const isVPS = service.toLowerCase() === "vps"; + useEffect(() => { + const billingFromUrl = searchParams?.get('billing'); + if (billingFromUrl && ['monthly', 'quarterly', 'semiannually', 'annually'].includes(billingFromUrl)) { + setSelectedCycle(billingFromUrl); + } + }, [searchParams]); + + const handleCycleChange = (cycle: string) => { + setSelectedCycle(cycle); + const currentPath = `/checkout/${service}/${planName}`; + const newUrl = `${currentPath}?billing=${cycle}`; + router.replace(newUrl, { scroll: false }); + }; + useEffect(() => { if (authContext && !authContext.isLoading && !authContext.user) { - const returnUrl = `/checkout/${service}/${planName}`; + const returnUrl = `/checkout/${service}/${planName}${searchParams?.toString() ? `?${searchParams.toString()}`: ''}`; router.push(`/login?redirect=${encodeURIComponent(returnUrl)}`); } - }, [authContext, router, service, planName]); + }, [authContext, router, service, planName, searchParams]); useEffect(() => { - if (!service || !planName) return; - + if (!service || !planName) { + setLoading(false); + setError("Serviço ou plano não especificado."); + return; + }; + const fetchPlan = async () => { setLoading(true); setError(null); try { - const apiUrl = - process.env.NEXT_PUBLIC_API_URL || - process.env.API_URL || - "http://localhost:4000"; + const apiUrl = config.api.baseUrl || "http://localhost:4000"; const response = await fetch( `${apiUrl}/v1/public/plans/${service}/${encodeURIComponent(planName)}`, ); @@ -158,7 +211,7 @@ const CheckoutPage = ({ setPlan(data); } catch (err) { setError( - `Plano "${planName}" não encontrado para o serviço "${service}".`, + `Plano "${planName}" não encontrado para o serviço "${service}".` ); setPlan(null); } finally { @@ -169,7 +222,34 @@ const CheckoutPage = ({ fetchPlan(); }, [service, planName]); - // Função para verificar cupom de desconto + const getCyclePrice = (cycle: string) => { + if (!plan) return 0; + const pricingCycle = cycle as keyof Plan['pricing']; + return plan.pricing[pricingCycle]?.valor || 0; + }; + + const getCycleName = (cycle: string) => { + const names: { [key: string]: string } = { + monthly: "Mensal", + quarterly: "Trimestral", + semiannually: "Semestral", + annually: "Anual", + }; + return names[cycle] || cycle; + }; + + const calculateDiscountedPrice = (originalPrice: number) => { + if (!appliedCoupon) return originalPrice; + if (appliedCoupon.type === "Percentage") { + const discountPercent = parseFloat(appliedCoupon.value); + return originalPrice - (originalPrice * discountPercent / 100); + } else if (appliedCoupon.type === "Fixed Amount") { + const discountAmount = parseFloat(appliedCoupon.value); + return Math.max(0, originalPrice - discountAmount); + } + return originalPrice; + }; + const verifyCoupon = async () => { if (!couponCode.trim()) { setCouponError("Digite um código de cupom"); @@ -211,14 +291,12 @@ const CheckoutPage = ({ return; } - // Verificar se o cupom se aplica ao plano atual const applicablePlans = foundCoupon.appliesto.split(',').map(id => parseInt(id.trim())); if (!applicablePlans.includes(plan.id)) { setCouponError("Este cupom não se aplica a este plano"); return; } - // Verificar se o cupom expirou if (foundCoupon.expirationdate !== "0000-00-00") { const expirationDate = new Date(foundCoupon.expirationdate); const currentDate = new Date(); @@ -228,7 +306,6 @@ const CheckoutPage = ({ } } - // Verificar usos máximos if (foundCoupon.maxuses > 0 && foundCoupon.uses >= foundCoupon.maxuses) { setCouponError("Este cupom atingiu o limite máximo de usos"); return; @@ -246,103 +323,15 @@ const CheckoutPage = ({ setVerifyingCoupon(false); } }; - - // Função para remover cupom aplicado const removeCoupon = () => { setAppliedCoupon(null); setCouponCode(""); setCouponError(null); }; - // Remover cupom quando o ciclo mudar (caso o cupom não seja aplicável ao novo ciclo) - useEffect(() => { - if (appliedCoupon) { - // Verificar se o cupom ainda é válido para o ciclo selecionado - // Por simplicidade, vamos manter o cupom, mas em uma implementação mais robusta - // seria necessário verificar se o cupom se aplica ao ciclo específico - } - }, [selectedCycle, appliedCoupon]); - - // Função para calcular preço com desconto - const calculateDiscountedPrice = (originalPrice: number) => { - if (!appliedCoupon) return originalPrice; - - if (appliedCoupon.type === "Percentage") { - const discountPercent = parseFloat(appliedCoupon.value); - return originalPrice - (originalPrice * discountPercent / 100); - } else if (appliedCoupon.type === "Fixed Amount") { - const discountAmount = parseFloat(appliedCoupon.value); - return Math.max(0, originalPrice - discountAmount); - } - - return originalPrice; - }; - - if (!authContext) { - return
Erro: AuthContext não foi encontrado.
; - } - - const { user, isLoading: authLoading } = authContext; - - if (authLoading) { - return ( -
-
-
-

- Verificando autenticação... -

-
-
- ); - } - - if (!user) { - return ( -
-
-
-

- Redirecionando para login... -

-
-
- ); - } - - const getCyclePrice = (cycle: string) => { - if (!plan) return 0; - switch (cycle) { - case "monthly": - return plan.pricing.monthly.valor; - case "quarterly": - return plan.pricing.quarterly?.valor || 0; - case "semiannually": - return plan.pricing.semiannually?.valor || 0; - case "annually": - return plan.pricing.annually?.valor || 0; - default: - return 0; - } - }; - - const getCycleName = (cycle: string) => { - switch (cycle) { - case "monthly": - return "Mensal"; - case "quarterly": - return "Trimestral"; - case "semiannually": - return "Semestral"; - case "annually": - return "Anual"; - default: - return cycle; - } - }; - + const handlePurchase = async () => { - if (!user) { + if (!authContext?.user) { router.push("/login"); return; } @@ -367,7 +356,7 @@ const CheckoutPage = ({ process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || "http://localhost:4000"; - const accessKey = authContext.accessKey; + const accessKey = authContext?.accessKey; if (!accessKey) { alert("Sessão expirada. Faça login novamente."); router.push("/login"); @@ -413,107 +402,31 @@ const CheckoutPage = ({ } }; - if (authLoading || (loading && user)) { - return ( -
-
-
-

- Carregando informações do plano... -

-
-
- ); - } - - if (error) { - return ( -
- -
- -
-

Erro

-

{error}

- -
-
- ); - } - - if (!plan) { - return null; - } + if (authContext?.isLoading || (loading && authContext?.user)) { return ; } + if (error) { return
{error}
; } + if (!plan) { return
Nenhum plano para exibir.
; } const features = [ - { - icon: , - text: plan.processor ? `${plan.processor}` : "", - }, - { - icon: , - text: plan.ram ? `${plan.ram}` : "", - }, - { - icon: , - text: plan.storage ? `${plan.storage}` : "", - }, - { - icon: , - text: plan.players ? `${plan.players} Jogadores` : "", - }, - { - icon: , - text: plan.subdomain ? `${plan.subdomain}` : "", - }, + { icon: , text: plan.processor || "" }, + { icon: , text: plan.ram || "" }, + { icon: , text: plan.storage || "" }, + { icon: , text: plan.players ? `${plan.players} Jogadores` : "" }, + { icon: , text: plan.subdomain || "" }, ].filter((feature) => feature.text); const renderMinecraftFields = () => ( - {/* Nome do Servidor */} - - setServerName(e.target.value)} - className="w-full bg-gray-700/50 border border-gray-600 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-300" - placeholder="Ex: Meu Servidor Incrível" - /> + + setServerName(e.target.value)} className="w-full bg-gray-700/50 border border-gray-600 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-300" placeholder="Ex: Meu Servidor Incrível" /> - - {/* Sistema a ser usado */} - +
- setEggId(e.target.value)} className="w-full bg-gray-700/50 border border-gray-600 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-300 appearance-none"> @@ -530,37 +443,17 @@ const CheckoutPage = ({ -
- - - -
+
); const renderVPSFields = () => ( - - + +
- setOsId(e.target.value)} className="w-full bg-gray-700/50 border border-gray-600 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-300 appearance-none"> @@ -571,15 +464,7 @@ const CheckoutPage = ({ -
- - - -
+
); @@ -590,37 +475,28 @@ const CheckoutPage = ({
-

- Processando seu pagamento -

-

- Estamos processando sua solicitação. Por favor, aguarde... -

+

Processando seu pagamento

+

Estamos processando sua solicitação. Por favor, aguarde...

)} -

- Finalizar sua compra + Quase lá! Finalize seu Pedido

- Complete suas informações para prosseguir para o pagamento + Revise os detalhes e configure seu novo serviço.

-
- {/* Coluna de detalhes do plano */} -
+
@@ -629,21 +505,14 @@ const CheckoutPage = ({

{plan.name}

- {isMinecraft ? ( - - ) : ( - - )} + {isMinecraft ? () : ()}
-
{features.map((feature, index) => (
@@ -653,13 +522,10 @@ const CheckoutPage = ({ ))}
-
Serviço - - {service.charAt(0).toUpperCase() + service.slice(1)} - + {service.charAt(0).toUpperCase() + service.slice(1)}
Plano @@ -667,56 +533,37 @@ const CheckoutPage = ({
Periodicidade - - {getCycleName(selectedCycle)} - + {getCycleName(selectedCycle)}
- - {/* Coluna de configuração e pagamento */}
+
-

Configure seu Servidor

+

1. Configure seu Servidor

- - {/* Campos específicos por tipo de serviço */} {isMinecraft && renderMinecraftFields()} {isVPS && renderVPSFields()} - - {/* Ciclo de Pagamento */} - - + +
{Object.keys(plan.pricing).map((cycle) => (
- - {/* Cupom de Desconto */} - -

- - Cupom de Desconto -

- + +

Cupom de Desconto

{!appliedCoupon ? (
- setCouponCode(e.target.value.toUpperCase())} - placeholder="Digite seu cupom de desconto" - className="flex-1 bg-gray-700/50 border border-gray-600 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-300" - disabled={verifyingCoupon} - /> -
- - {couponError && ( -
- - {couponError} -
- )} + {couponError && (
{couponError}
)}
) : (
-
- -
+
-

- Cupom "{appliedCoupon.code}" aplicado! -

-

- {appliedCoupon.type === "Percentage" - ? `${appliedCoupon.value}% de desconto` - : `R$ ${parseFloat(appliedCoupon.value).toFixed(2).replace(".", ",")} de desconto` - } -

+

Cupom "{appliedCoupon.code}" aplicado!

+

{appliedCoupon.type === "Percentage" ? `${appliedCoupon.value}% de desconto` : `R$ ${parseFloat(appliedCoupon.value).toFixed(2).replace(".", ",")} de desconto`}

- +
)}
- - {/* Resumo do Pedido */} - -

- - Resumo do Pedido -

- -
-
- Plano {plan.name} - - R${" "} - {getCyclePrice(selectedCycle) - .toFixed(2) - .replace(".", ",")} - -
- -
- Ciclo - {getCycleName(selectedCycle)} -
- + +

2. Resumo do Pedido

+
+
Plano {plan.name}R$ {getCyclePrice(selectedCycle).toFixed(2).replace(".", ",")}
+
Ciclo{getCycleName(selectedCycle)}
{appliedCoupon && (
Desconto ({appliedCoupon.code}) - - -R${" "} - {(getCyclePrice(selectedCycle) - calculateDiscountedPrice(getCyclePrice(selectedCycle))) - .toFixed(2) - .replace(".", ",")} - + -R$ {(getCyclePrice(selectedCycle) - calculateDiscountedPrice(getCyclePrice(selectedCycle))).toFixed(2).replace(".", ",")}
)} -
Total - - R${" "} - {calculateDiscountedPrice(getCyclePrice(selectedCycle)) - .toFixed(2) - .replace(".", ",")} - + R$ {calculateDiscountedPrice(getCyclePrice(selectedCycle)).toFixed(2).replace(".", ",")}
- {appliedCoupon && (
- - R$ {getCyclePrice(selectedCycle).toFixed(2).replace(".", ",")} - - - Você economiza R${" "} - {(getCyclePrice(selectedCycle) - calculateDiscountedPrice(getCyclePrice(selectedCycle))) - .toFixed(2) - .replace(".", ",")} - + R$ {getCyclePrice(selectedCycle).toFixed(2).replace(".", ",")} + Você economiza R$ {(getCyclePrice(selectedCycle) - calculateDiscountedPrice(getCyclePrice(selectedCycle))).toFixed(2).replace(".", ",")}
)}
- - - - - Pagamento seguro processado por nossa plataforma - - - - {processingPayment ? ( - <> -
- Processando... - - ) : ( - "Finalizar Compra" - )} + {processingPayment ? (<>
Processando...) : ("Finalizar Compra")}
+
+
Pagamento 100% Seguro
+

Seus dados são protegidos com criptografia de ponta.

+
@@ -897,4 +641,11 @@ const CheckoutPage = ({ ); }; +const CheckoutPage = () => ( + Carregando...
}> + + +); + + export default CheckoutPage; diff --git a/src/app/lgpd/page.tsx b/src/app/lgpd/page.tsx index 54a1aed..ae7d9fb 100644 --- a/src/app/lgpd/page.tsx +++ b/src/app/lgpd/page.tsx @@ -106,10 +106,10 @@ export default function LGPDPage() {

- E-mail: lgpd@firehosting.com + E-mail: lgpd@firehosting.com.br

- Telefone: (XX) XXXX-XXXX + Telefone: + 55 (75) 9121-2392

diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 0ff8ccd..8c0bfb3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -8,7 +8,7 @@ import { FaDiscord, FaMagic, FaEye, - FaEyeSlash, + FaEyeSlash } from "react-icons/fa"; import { AuthContext, useAuth } from "@/contexts/AuthContext"; @@ -25,9 +25,68 @@ function LoginContent() { const [showMagicLink, setShowMagicLink] = useState(false); const [isLoading, setIsLoading] = useState(false); const [magicLinkSent, setMagicLinkSent] = useState(false); + const [activeProvider, setActiveProvider] = useState<"google" | "discord" | null>(null); const [error, setError] = useState(""); + + // Lê erro da query string para exibir mensagem amigável + useEffect(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const err = params.get("error"); + const provider = params.get("provider") || "social"; + + if (err) { + let msg = `Erro ao autenticar com ${provider === "google" ? "Google" : "Discord"}.`; + if (err === "usuario_nao_cadastrado") msg = "Usuário não cadastrado. Cadastre-se para continuar."; + else if (err === "codigo_invalido") msg = `Código do ${provider === "google" ? "Google" : "Discord"} inválido. Tente novamente.`; + else if (err === "discord_user_email" || err === "google_user_email") msg = `Não foi possível obter dados do ${provider === "google" ? "Google" : "Discord"}. Permita acesso ao email.`; + else if (err === "db") msg = "Erro interno ao salvar dados. Tente novamente."; + setError(msg); + } + } + }, []); const [successMessage, setSuccessMessage] = useState(""); + + // Verificar se há um token de magic link na URL + useEffect(() => { + if (typeof window !== "undefined" && !isAuthenticated && !isLoading) { + const params = new URLSearchParams(window.location.search); + const method = params.get("method"); + const token = params.get("token"); + + if (method === "magiclink" && token) { + // Remover os parâmetros da URL para evitar problemas de segurança + const cleanUrl = window.location.pathname; + window.history.replaceState({}, document.title, cleanUrl); + + // Tentar fazer login com o token magic link + setIsLoading(true); + setError(""); + + const handleMagicLinkLogin = async () => { + try { + const result = await login("", undefined, "magiclink", token); + + if (!result.success) { + setError(result.message || "Link mágico inválido ou expirado"); + } else { + setSuccessMessage("Login realizado com sucesso!"); + // O redirecionamento é feito pelo useEffect que monitora isAuthenticated + } + } catch (err) { + console.error("Magic link login error:", err); + setError("Erro ao processar o link mágico. Tente novamente ou use outro método de login."); + } finally { + setIsLoading(false); + } + }; + + handleMagicLinkLogin(); + } + } + }, [isAuthenticated, isLoading, login]); + useEffect(() => { if (isAuthenticated) { if (redirect) { @@ -63,20 +122,36 @@ function LoginContent() { setIsLoading(true); setError(""); - const result = await login(magicLinkEmail, undefined, "emaillink"); + try { + const result = await login(magicLinkEmail, undefined, "emaillink"); - if (result.success) { - setMagicLinkSent(true); - setSuccessMessage(result.message || "Link mágico enviado!"); - } else { - setError(result.message || "Erro ao enviar link mágico"); + if (result.success) { + setMagicLinkSent(true); + setSuccessMessage(result.message || "Link mágico enviado! Verifique sua caixa de entrada."); + } else { + setError(result.message || "Erro ao enviar link mágico"); + } + } catch (err) { + console.error("Magic link error:", err); + setError("Erro ao enviar link mágico. Tente novamente."); + } finally { + setIsLoading(false); } - - setIsLoading(false); }; - const handleSocialLogin = (provider: "google" | "discord") => { - console.log(`${provider} login - Em breve disponível`); + // Social OAuth login handler + const handleSocialLogin = async (provider: "google" | "discord") => { + setError(""); + setIsLoading(true); + setActiveProvider(provider); + try { + await login("", undefined, provider); + } catch (error) { + console.error(`${provider} login error:`, error); + setError(`Erro ao conectar com ${provider === "google" ? "Google" : "Discord"}`); + } finally { + // Não definimos setIsLoading(false) aqui porque o usuário será redirecionado + } }; return ( @@ -111,6 +186,14 @@ function LoginContent() { {successMessage}
)} + + {/* Magic Link Login Processing */} + {isLoading && searchParams && searchParams.get("method") === "magiclink" && ( +
+
+

Verificando link mágico...

+
+ )} {!showMagicLink ? ( <> @@ -177,33 +260,32 @@ function LoginContent() {
- {/* Magic Link Button */} + {/* Social Login */}
diff --git a/src/app/sac/page.tsx b/src/app/sac/page.tsx index 1a978d9..a1b7cb4 100644 --- a/src/app/sac/page.tsx +++ b/src/app/sac/page.tsx @@ -344,15 +344,15 @@ export default function ContactPage() {
- {/* Telegram */} + {/* Discord */}
- +
-

Telegram

+

Discord

- Envie-nos uma mensagem no Telegram. + Abra um chamado no nosso Discord para suporte técnico.

@@ -362,7 +362,7 @@ export default function ContactPage() { } className="w-10 h-10 bg-blue-500 hover:bg-blue-600 hover:scale-110 rounded-lg flex items-center justify-center transition-all duration-200" > - +
diff --git a/src/app/services/minecraft/page.tsx b/src/app/services/minecraft/page.tsx index 22827d4..d28d051 100644 --- a/src/app/services/minecraft/page.tsx +++ b/src/app/services/minecraft/page.tsx @@ -28,7 +28,7 @@ const benefits = [ icon: FiServer, title: "Hardware de Alta Performance", description: - "Servidores equipados com processadores Ryzen9 e SSDs NVMe para garantir uma experiência de jogo sem lag.", + "Servidores BareMetal equipados com processadores Ryzen 9 e SSDs NVMe Samsung Evo para garantir uma experiência de jogo sem lag.", }, { icon: FiShield, @@ -231,10 +231,7 @@ export default function MinecraftHostingPage() {

Precisa de um plano personalizado? - + Entre em contato conosco

diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 7c5ef90..aa350cd 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -20,6 +20,7 @@ const services = [ link: "/services/minecraft", icon: SiMinecraft, features: [ + "AMD Ryzen 9 7900X 5,6 GHz", "Hardware de Alta Performance", "Proteção DDoS Avançada", "Painel de Controle Intuitivo", @@ -35,6 +36,7 @@ const services = [ link: "/services/vps", icon: FiCpu, features: [ + "AMD Ryzen 9 7900X 5,6 GHz", "Acesso Root Completo", "Armazenamento SSD NVMe", "Painel de Controle Virtualizor", diff --git a/src/components/home/Services.tsx b/src/components/home/Services.tsx index b29c76f..d413964 100644 --- a/src/components/home/Services.tsx +++ b/src/components/home/Services.tsx @@ -22,6 +22,8 @@ const Services = () => { description: "Hospedagem otimizada para servidores Minecraft com suporte a plugins, mods e alta performance.", features: [ + "AMD Ryzen 9 7900X 5,6 GHz", + "Suporte a Versões 1.8 - 1.20+", "Suporte a Plugins", "Mods Compatíveis", "Alta Performance", @@ -36,6 +38,7 @@ const Services = () => { description: "VPS dedicado com recursos escaláveis, alta performance e acesso root completo para personalização total.", features: [ + "AMD Ryzen 9 7900X 5,6 GHz", "Recursos Dedicados", "Acesso Root Completo", "SSD NVMe", @@ -64,8 +67,9 @@ const Services = () => { description: "Hospedagem de aplicações e bots com suporte a Node.js, Python e outras linguagens, ideal para desenvolvedores.", features: [ + "AMD Ryzen 9 7900X 5,6 GHz", "Suporte a Node.js", - "Python e Ruby", + "Python, Ruby, PHP, Java, Elixir, Golang", "Escalabilidade Automática", "Monitoramento em Tempo Real", ], diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 7d9d1a8..a2bd8d4 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { FiMail, FiPhone, FiMapPin, FiExternalLink } from "react-icons/fi"; -import { SiDiscord, SiTwitter, SiGithub, SiInstagram } from "react-icons/si"; +import { SiDiscord, SiTwitter, SiGithub, SiInstagram, SiTiktok, SiYoutube } from "react-icons/si"; import { VscSourceControl } from "react-icons/vsc"; const Footer = () => { @@ -76,6 +76,22 @@ const Footer = () => { >
+ + + + + +
@@ -157,7 +173,9 @@ const Footer = () => {
© {currentYear} FireHosting - Todos os direitos reservados.
- Desenvolvido com ❤️ por{" "} + CNPJ: 61.967.228/0001-40 +
+ Desenvolvido com ❤️ e muito ☕ por{" "} ([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [billingCycle, setBillingCycle] = useState<"monthly" | "annually">( + "annually" + ); const auth = useAuth(); const router = useRouter(); + const isAnnualBillingAvailable = plans.some( + (plan) => plan.pricing.annually && plan.pricing.annually.valor > 0 + ); + useEffect(() => { const fetchPlans = async () => { try { setLoading(true); const response = await fetch( - `${config.api.baseUrl}/v1/public/plans/minecraft`, + `${config.api.baseUrl}/v1/public/plans/minecraft` ); if (!response.ok) { throw new Error( - "Falha ao buscar os planos. Por favor, tente novamente mais tarde.", + "Falha ao buscar os planos. Por favor, tente novamente mais tarde." ); } const data = await response.json(); @@ -117,9 +134,45 @@ export default function MinecraftPlans() { featured: index === 1, })); setPlans(plansWithFeatured); + + const hasAnnual = plansWithFeatured.some( + (p) => p.pricing.annually && p.pricing.annually.valor > 0 + ); + if (!hasAnnual) { + setBillingCycle("monthly"); + } } catch (err) { setError( - err instanceof Error ? err.message : "Ocorreu um erro desconhecido.", + err instanceof Error ? err.message : "Ocorreu um erro desconhecido." + ); + } finally { + setLoading(false); + } + }; + fetchPlans(); + }, []); + + useEffect(() => { + const fetchPlans = async () => { + try { + setLoading(true); + const response = await fetch( + `${config.api.baseUrl}/v1/public/plans/minecraft` + ); + if (!response.ok) { + throw new Error( + "Falha ao buscar os planos. Por favor, tente novamente mais tarde." + ); + } + const data = await response.json(); + const plansWithFeatured = data.map((plan: Plan, index: number) => ({ + ...plan, + featured: index === 1, + })); + setPlans(plansWithFeatured); + } catch (err) { + setError( + err instanceof Error ? err.message : "Ocorreu um erro desconhecido." ); } finally { setLoading(false); @@ -130,10 +183,10 @@ export default function MinecraftPlans() { const handleBuyClick = (planName: string) => { const lowerCasePlanName = planName.toLowerCase(); + const redirectUrl = `/checkout/minecraft/${lowerCasePlanName}?billing=${billingCycle}`; if (auth && auth.isAuthenticated) { - router.push(`/checkout/minecraft/${lowerCasePlanName}`); + router.push(redirectUrl); } else { - const redirectUrl = `/checkout/minecraft/${lowerCasePlanName}`; router.push(`/login?redirect=${encodeURIComponent(redirectUrl)}`); } }; @@ -158,64 +211,120 @@ export default function MinecraftPlans() { } return ( -
- {plans.map((plan) => ( -
- {plan.featured && ( -
- Popular +
+ {isAnnualBillingAvailable && ( +
+ Mensal +
setBillingCycle(billingCycle === "monthly" ? "annually" : "monthly")} + className="w-14 h-8 flex items-center bg-secondary border-2 border-detail rounded-full p-1 cursor-pointer transition-colors duration-300" + > +
- )} - -
-

{plan.name}

-
- - {formatCurrency(plan.pricing.monthly.valor)} - +
+ Anual + ECONOMIZE 20%
-

por mês

-
+
+ )} -
-
- - {plan.vcpu} vCPU -
-
- - {plan.ram} RAM -
-
- - {plan.storage} +
+ {plans.map((plan) => { + const monthlyPrice = plan.pricing.monthly.valor; + const annualPrice = plan.pricing.annually + ? plan.pricing.annually.valor + : monthlyPrice * 12 * 0.75; + const isAnnual = billingCycle === "annually"; + + const displayPrice = isAnnual ? annualPrice / 12 : monthlyPrice; + const originalPrice = isAnnual ? monthlyPrice : monthlyPrice * 1.15; + + return ( +
+ {plan.featured && ( +
+ MAIS POPULAR +
+ )} + +
+

{plan.name}

+ +
+ + {formatCurrency(originalPrice)} + + + + + +
+

+ /mês +

+ {/* NOVO: Tag de oferta agora é exibida em ambos os ciclos */} +
+ OFERTA POR TEMPO LIMITADO! +
+
+ +
+
+ + {plan.vcpu} vCPU +
+
+ + {plan.ram} RAM +
+
+ + {plan.storage} +
+
+ +
    + {features.map((feature, i) => ( +
  • + {feature.icon} + + {feature.text} + +
  • + ))} +
+ +
-
- -
    - {features.map((feature, i) => ( -
  • - {feature.icon} - {feature.text} -
  • - ))} -
- - -
- ))} + ); + })} +
); } diff --git a/src/components/vps/VpsPlans.tsx b/src/components/vps/VpsPlans.tsx index 94b8293..75abb83 100644 --- a/src/components/vps/VpsPlans.tsx +++ b/src/components/vps/VpsPlans.tsx @@ -9,7 +9,6 @@ import { FaNetworkWired, FaShieldAlt, FaHeadset, - } from "react-icons/fa"; import config from "../../../config.json"; import { useAuth } from "../../contexts/AuthContext"; @@ -22,6 +21,15 @@ interface Plan { monthly: { valor: number; }; + quarterly?: { + valor: number; + }; + semiannually?: { + valor: number; + }; + annually?: { + valor: number; + }; }; link: string; vcpu: string; @@ -94,22 +102,22 @@ const VpsPlans = () => { try { setLoading(true); const response = await fetch( - `${config.api.baseUrl}/v1/public/plans/vps`, + `${config.api.baseUrl}/v1/public/plans/vps` ); if (!response.ok) { throw new Error( - "Falha ao buscar os planos VPS. Por favor, tente novamente mais tarde.", + "Falha ao buscar os planos VPS. Por favor, tente novamente mais tarde." ); } const data = await response.json(); const plansWithFeatured = data.map((plan: Plan, index: number) => ({ ...plan, - featured: index === 1, // Mark the second plan as featured + featured: index === 1, })); setPlans(plansWithFeatured); } catch (err) { setError( - err instanceof Error ? err.message : "Ocorreu um erro desconhecido.", + err instanceof Error ? err.message : "Ocorreu um erro desconhecido." ); } finally { setLoading(false); @@ -150,67 +158,81 @@ const VpsPlans = () => { return (
- {plans.map((plan) => ( -
- {plan.featured && ( -
- Popular -
- )} - -
-

{plan.name}

-
- - {formatCurrency(plan.pricing.monthly.valor)} - + {plans.map((plan) => { + const currentPrice = plan.pricing.monthly.valor; + const originalPrice = currentPrice * 1.15; + + return ( +
+ {plan.featured && ( +
+ MAIS POPULAR +
+ )} + +
+

{plan.name}

+ +
+ + {formatCurrency(originalPrice)} + + + {formatCurrency(currentPrice)} + +
+

/mês

+
+ OFERTA POR TEMPO LIMITADO! +
-

por mês

-
-
-
- - {plan.vcpu} vCPU -
-
- - {plan.ram} RAM -
-
- - {plan.storage} SSD NVMe -
-
- - {plan.ips} IPv4 +
+
+ + {plan.vcpu} vCPU +
+
+ + {plan.ram} RAM +
+
+ + {plan.storage} SSD NVMe +
+
+ + {plan.ips} IPv4 +
-
-
    - {commonFeatures.map((feature, i) => ( -
  • - {feature.icon} - {feature.text} -
  • - ))} -
- - -
- ))} +
    + {commonFeatures.map((feature, i) => ( +
  • + {feature.icon} + {feature.text} +
  • + ))} +
+ + +
+ ); + })}
); }; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 713c691..8f2c520 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -12,7 +12,6 @@ import { loginUser } from "../services/api/auth"; import { fetchUserDataAPI } from "../services/api/user"; import { User } from "../types/user"; -// Re-exporting types for convenience if they are used by components that also use this context export type { User }; interface AuthContextValue { @@ -21,6 +20,7 @@ interface AuthContextValue { email: string, password?: string, method?: string, + code?: string ) => Promise<{ success: boolean; message?: string }>; logout: () => void; isLoading: boolean; @@ -29,7 +29,7 @@ interface AuthContextValue { } export const AuthContext = createContext( - undefined, + undefined ); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -47,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; const fetchUserData = async ( - token: string | null, + token: string | null ): Promise<{ success: boolean; message?: string }> => { if (!token) { clearAuthData(); @@ -91,8 +91,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; syncAuth(); const interval = setInterval(() => { - const token = getCookie("access_key") || localStorage.getItem("access_key"); - const session = getCookie("session_cookie") || localStorage.getItem("session_cookie"); + const token = + getCookie("access_key") || localStorage.getItem("access_key"); + const session = + getCookie("session_cookie") || localStorage.getItem("session_cookie"); if (!token || !session) { clearAuthData(); setIsLoading(false); @@ -104,14 +106,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const login = async ( - email: string, + email: string = "", password?: string, method: string = "sso", + code?: string ): Promise<{ success: boolean; message?: string }> => { setIsLoading(true); - const result = await loginUser(email, password, method); + if (method === "discord") { + window.location.href = "/api/auth/discord-redirect"; + setIsLoading(false); + return { success: false }; + } else if (method === "google") { + window.location.href = "/api/auth/google-redirect"; + setIsLoading(false); + return { success: false }; + } + + const result = await loginUser(email, password, method, code); if (result.success) { - if (method === "sso" && result.access_key && result.session_cookie) { + if ((method === "sso" || method === "magiclink") && result.access_key && result.session_cookie) { setCookie("access_key", result.access_key, 7); setCookie("session_cookie", result.session_cookie, 7); localStorage.setItem("access_key", result.access_key); @@ -127,6 +140,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const fetchResult = await fetchUserData(result.access_key); setIsLoading(false); return fetchResult; + } else if (method === "emaillink") { + setIsLoading(false); + return { success: true, message: result.message }; } } setIsLoading(false); diff --git a/src/pages/api/auth/discord-redirect.ts b/src/pages/api/auth/discord-redirect.ts new file mode 100644 index 0000000..ebba8bc --- /dev/null +++ b/src/pages/api/auth/discord-redirect.ts @@ -0,0 +1,25 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const apiRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "discord" }) + }); + if (apiRes.redirected) { + return res.redirect(apiRes.url); + } + if (apiRes.status === 302) { + const location = apiRes.headers.get("Location"); + if (location) return res.redirect(location); + } + const data = await apiRes.json(); + if (data && data.url) { + return res.redirect(data.url); + } + return res.status(400).json({ error: "URL de redirecionamento não recebida" }); + } catch (e) { + return res.status(500).json({ error: "Erro ao redirecionar para o Discord" }); + } +} diff --git a/src/pages/api/auth/discord/callback.ts b/src/pages/api/auth/discord/callback.ts new file mode 100644 index 0000000..58a9c29 --- /dev/null +++ b/src/pages/api/auth/discord/callback.ts @@ -0,0 +1,40 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + if (!code || typeof code !== "string") { + return res.status(400).json({ error: "Code não fornecido" }); + } + + try { + const apiRes = await fetch(process.env.NEXT_PUBLIC_API_URL + "/v1/users/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "discord", code }) + }); + const data = await apiRes.json(); + if (data.result === "success") { + if (data.access_key) { + const cookies: string[] = []; + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toUTCString(); + const base = `;expires=${expires};path=/;samesite=strict`; + const isHttps = req.headers["x-forwarded-proto"] === "https" || req.headers.host?.startsWith("https"); + const secure = isHttps ? ";secure" : ""; + cookies.push(`access_key=${data.access_key}${base}${secure}`); + if (data.session_cookie) cookies.push(`session_cookie=${data.session_cookie}${base}${secure}`); + if (data.userid) cookies.push(`user_id=${data.userid}${base}${secure}`); + res.setHeader("Set-Cookie", cookies); + } + res.redirect("/dashboard"); + } else { + let errorMsg = "discord"; + if (data.error === "usuario não cadastrado") errorMsg = "usuario_nao_cadastrado"; + else if (data.error === "Invalid Discord code") errorMsg = "codigo_invalido"; + else if (data.error === "Unable to fetch Discord user or email") errorMsg = "discord_user_email"; + else if (data.error === "Database error") errorMsg = "db"; + res.redirect(`/login?error=${errorMsg}`); + } + } catch (e) { + res.redirect("/login?error=discord"); + } +} diff --git a/src/pages/api/auth/google-redirect.ts b/src/pages/api/auth/google-redirect.ts new file mode 100644 index 0000000..6bdb610 --- /dev/null +++ b/src/pages/api/auth/google-redirect.ts @@ -0,0 +1,25 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const apiRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "google" }) + }); + if (apiRes.redirected) { + return res.redirect(apiRes.url); + } + if (apiRes.status === 302) { + const location = apiRes.headers.get("Location"); + if (location) return res.redirect(location); + } + const data = await apiRes.json(); + if (data && data.url) { + return res.redirect(data.url); + } + return res.status(400).json({ error: "URL de redirecionamento não recebida" }); + } catch (e) { + return res.status(500).json({ error: "Erro ao redirecionar para o Google" }); + } +} diff --git a/src/pages/api/auth/google/callback.ts b/src/pages/api/auth/google/callback.ts new file mode 100644 index 0000000..75b9fd0 --- /dev/null +++ b/src/pages/api/auth/google/callback.ts @@ -0,0 +1,41 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + if (!code || typeof code !== "string") { + return res.status(400).json({ error: "Code não fornecido" }); + } + + try { + const apiRes = await fetch(process.env.NEXT_PUBLIC_API_URL + "/v1/users/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "google", code }) + }); + const data = await apiRes.json(); + if (data.result === "success") { + if (data.access_key) { + const cookies: string[] = []; + const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toUTCString(); + const base = `;expires=${expires};path=/;samesite=strict`; + const isHttps = req.headers["x-forwarded-proto"] === "https" || req.headers.host?.startsWith("https"); + const secure = isHttps ? ";secure" : ""; + cookies.push(`access_key=${data.access_key}${base}${secure}`); + if (data.session_cookie) cookies.push(`session_cookie=${data.session_cookie}${base}${secure}`); + if (data.userid) cookies.push(`user_id=${data.userid}${base}${secure}`); + res.setHeader("Set-Cookie", cookies); + } + res.redirect("/dashboard"); + } else { + let errorMsg = "google"; + let provider = "google"; + if (data.error === "usuario não cadastrado") errorMsg = "usuario_nao_cadastrado"; + else if (data.error === "Invalid Google code") errorMsg = "codigo_invalido"; + else if (data.error === "Unable to fetch Google user or email") errorMsg = "google_user_email"; + else if (data.error === "Database error") errorMsg = "db"; + res.redirect(`/login?error=${errorMsg}&provider=${provider}`); + } + } catch (e) { + res.redirect("/login?error=google&provider=google"); + } +} diff --git a/src/pages/api/websocket-proxy.ts b/src/pages/api/websocket-proxy.ts index cf4d2c7..7678165 100644 --- a/src/pages/api/websocket-proxy.ts +++ b/src/pages/api/websocket-proxy.ts @@ -1,12 +1,11 @@ import { createProxyMiddleware, Options } from "http-proxy-middleware"; import type { NextApiRequest, NextApiResponse } from "next"; -import { URL } from "url"; // Import URL for validation +import { URL } from "url"; -// It's crucial that Next.js doesn't close the connection before the proxy can handle the upgrade. export const config = { api: { - bodyParser: false, // Essential for proxying, especially for WebSockets - externalResolver: true, // Allows the proxy to handle the response + bodyParser: false, + externalResolver: true, }, }; @@ -14,34 +13,31 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { const socketUrl = req.query.socketUrl as string; console.log( - `[API /websocket-proxy] Request received. Method: ${req.method}, URL: ${req.url}`, + `[API /websocket-proxy] Request received. Method: ${req.method}, URL: ${req.url}` ); - // console.log(`[API /websocket-proxy] Request headers: ${JSON.stringify(req.headers)}`); // Uncomment for verbose header logging if needed - // WebSocket handshake must be a GET request with an Upgrade header const isUpgradeRequest = req.method === "GET" && req.headers.upgrade && req.headers.upgrade.toLowerCase() === "websocket"; console.log( - `[API /websocket-proxy] Is WebSocket upgrade request? ${isUpgradeRequest}`, + `[API /websocket-proxy] Is WebSocket upgrade request? ${isUpgradeRequest}` ); if (!socketUrl) { console.log( - "[API /websocket-proxy] Error: Missing socketUrl query parameter.", + "[API /websocket-proxy] Error: Missing socketUrl query parameter." ); res.status(400).send("Missing socketUrl query parameter"); return; } - // Validate socketUrl try { const parsedUrl = new URL(socketUrl); if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") { console.log( - `[API /websocket-proxy] Error: Invalid socketUrl protocol: ${parsedUrl.protocol}`, + `[API /websocket-proxy] Error: Invalid socketUrl protocol: ${parsedUrl.protocol}` ); res.status(400).send("Invalid socketUrl protocol. Must be ws: or wss:"); return; @@ -49,7 +45,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { } catch (error) { console.log( "[API /websocket-proxy] Error: Invalid socketUrl format.", - error, + error ); res.status(400).send("Invalid socketUrl format."); return; @@ -57,69 +53,56 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { console.log(`[API /websocket-proxy] Target WebSocket URL: ${socketUrl}`); - // If it's not a WebSocket upgrade request, respond accordingly. if (!isUpgradeRequest) { console.log( - "[API /websocket-proxy] Not a WebSocket upgrade request. Responding 426 Upgrade Required.", + "[API /websocket-proxy] Not a WebSocket upgrade request. Responding 426 Upgrade Required." ); - // Inform the client that this endpoint expects a WebSocket upgrade. res.setHeader("Upgrade", "websocket"); res.setHeader("Connection", "Upgrade"); res .status(426) .send( - "Upgrade Required. This endpoint is for WebSocket connections only.", + "Upgrade Required. This endpoint is for WebSocket connections only." ); return; } const proxyMiddleware = createProxyMiddleware({ target: - process.env.WEBSOCKET_TARGET_URL || "https://painel.firehosting.com.br", // Ensure target is defined - ws: true, // Enable WebSocket proxying - changeOrigin: true, // Important for the target server to accept the request + process.env.WEBSOCKET_TARGET_URL || "https://painel.firehosting.com.br", + ws: true, + changeOrigin: true, on: { proxyReqWs: (proxyReq, req, socket, options, head) => { - // This is where we modify the outgoing WebSocket handshake request proxyReq.setHeader("Origin", "https://painel.firehosting.com.br"); console.log( - "[WebSocket Proxy] Modifying WebSocket handshake request headers", + "[WebSocket Proxy] Modifying WebSocket handshake request headers" ); }, error: (err, req, res, target) => { console.error( - `[WebSocket Proxy] Proxy error for target ${typeof target === "string" ? target : JSON.stringify(target)}:`, - err, + `[WebSocket Proxy] Proxy error for target ${ + typeof target === "string" ? target : JSON.stringify(target) + }:`, + err ); - // Check if res is an HTTP response object if ("writeHead" in res) { res.writeHead(500, { "Content-Type": "text/plain", }); res.end("WebSocket proxy error."); } else { - // If it's not an HTTP response, it might be a socket from a WebSocket error. - // We can't send an HTTP response, but we can try to close/destroy the socket. console.warn( - "[WebSocket Proxy] Error occurred on a WebSocket connection. Attempting to close socket.", + "[WebSocket Proxy] Error occurred on a WebSocket connection. Attempting to close socket." ); if (typeof (res as any).destroy === "function") { (res as any).destroy(); } else if (typeof (res as any).end === "function") { - // Some socket-like objects might use .end() (res as any).end(); } } }, }, }); - - console.log( - "[API /websocket-proxy] Passing WebSocket upgrade request to http-proxy-middleware.", - ); - // @ts-ignore http-proxy-middleware expects (req, res, next?) but types might not fully align with NextApiRequest/Response - // HPM will take over the request/response for WebSocket upgrades. - // Because externalResolver is true, Next.js expects this handler to manage the response. - // HPM does this by writing the 101 response for upgrades or proxying HTTP. return proxyMiddleware(req, res); } diff --git a/src/services/api/auth.ts b/src/services/api/auth.ts index 6eba9dd..5313f84 100644 --- a/src/services/api/auth.ts +++ b/src/services/api/auth.ts @@ -38,6 +38,7 @@ export const loginUser = async ( email: string, password?: string, method: string = "sso", + code?: string ): Promise<{ success: boolean; message?: string; @@ -60,9 +61,25 @@ export const loginUser = async ( }; } else if (method === "emaillink") { requestBody = { - type: "emaillink", + type: "magiclink", email: email, }; + } else if (method === "magiclink") { + if (!code) { + return { success: false, message: "Token do Magic Link não informado." }; + } + requestBody = { + type: "magiclink", + token: code, + }; + } else if (method === "discord") { + if (!code) { + return { success: false, message: "Código do Discord não informado." }; + } + requestBody = { + type: "discord", + code: code, + }; } else { return { success: false, message: "Invalid login method specified." }; } @@ -96,6 +113,18 @@ export const loginUser = async ( message: "Link mágico enviado para o seu e-mail. Verifique sua caixa de entrada.", }; + } else if (method === "magiclink" && data.result === "success") { + return { + success: true, + access_key: data.access_key, + session_cookie: data.session_cookie, + }; + } else if (method === "discord" && data.result === "success") { + return { + success: true, + access_key: data.access_key, + session_cookie: data.session_cookie, + }; } else { return { success: false, From 8a9e317f1241ed48b1429331dcf1c5873ca90fd8 Mon Sep 17 00:00:00 2001 From: XDuke Date: Sat, 2 Aug 2025 22:32:56 -0300 Subject: [PATCH 8/8] feat: Update API URLs and implement social login functionality for Discord and Google --- .env | 4 +- config.json | 2 +- src/app/login/page.tsx | 1 - src/components/dashboard/SecurityContent.tsx | 414 +++++++++++++++++-- src/pages/api/auth/discord-link.ts | 63 +++ src/pages/api/auth/google-link.ts | 62 +++ 6 files changed, 513 insertions(+), 33 deletions(-) create mode 100644 src/pages/api/auth/discord-link.ts create mode 100644 src/pages/api/auth/google-link.ts diff --git a/.env b/.env index ac67a9f..db4cc05 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -API_URL=https://api-dev.firehosting.com.br -NEXT_PUBLIC_API_URL=https://api-dev.firehosting.com.br +API_URL=https://system-api.firehosting.com.br +NEXT_PUBLIC_API_URL=https://system-api.firehosting.com.br NEXT_PUBLIC_SOCKET_URL=https://firehosting-socket.squareweb.app NEXT_PUBLIC_CENTRAL_URL=https://central.firehosting.com.br \ No newline at end of file diff --git a/config.json b/config.json index 50772b9..a1d5d09 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "api": { - "baseUrl": "https://api-dev.firehosting.com.br", + "baseUrl": "https://system-api.firehosting.com.br", "authKey": "CjYYooDNiVgzWBJPNlYyIUfoxJRtLozDFiTAoHdQuPAnxFEAuK" } } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8c0bfb3..bbddf9f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -48,7 +48,6 @@ function LoginContent() { const [successMessage, setSuccessMessage] = useState(""); - // Verificar se há um token de magic link na URL useEffect(() => { if (typeof window !== "undefined" && !isAuthenticated && !isLoading) { const params = new URLSearchParams(window.location.search); diff --git a/src/components/dashboard/SecurityContent.tsx b/src/components/dashboard/SecurityContent.tsx index 4ef5e06..a7b476c 100644 --- a/src/components/dashboard/SecurityContent.tsx +++ b/src/components/dashboard/SecurityContent.tsx @@ -9,17 +9,43 @@ import { FaEyeSlash, FaLink, FaSpinner, + FaCheck, + FaTimes, + FaUnlink, } from "react-icons/fa"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useAuth } from "@/contexts/AuthContext"; +import { useSearchParams } from "next/navigation"; +import { getCookie } from "@/utils/cookies"; export default function SecurityContent() { - const { accessKey } = useAuth(); + const { accessKey: contextAccessKey } = useAuth(); + const [accessKey, setAccessKey] = useState(contextAccessKey || getCookie("access_key")); const [showNewPassword, setShowNewPassword] = useState(false); + + useEffect(() => { + const token = contextAccessKey || getCookie("access_key"); + setAccessKey(token); + + if (!token) { + console.warn("Authentication token not found in context or cookies"); + } else { + console.log("Authentication token loaded successfully", { source: token === contextAccessKey ? 'context' : 'cookie' }); + } + }, [contextAccessKey]); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [socialConnections, setSocialConnections] = useState({ + google: false, + discord: false, + }); + const [loadingSocial, setLoadingSocial] = useState({ + google: false, + discord: false, + }); + const [isFetchingSocial, setIsFetchingSocial] = useState(true); const [passwordData, setPasswordData] = useState({ new: "", @@ -27,6 +53,193 @@ export default function SecurityContent() { }); const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'; + const searchParams = useSearchParams(); + + const fetchSocialConnections = async () => { + const currentToken = accessKey || getCookie("access_key"); + if (!currentToken) { + showTemporaryMessage("Sessão expirada. Faça login novamente.", true); + return; + } + + setIsFetchingSocial(true); + try { + const response = await fetch(`${apiUrl}/v1/users/me/login/social`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${currentToken}`, + 'Content-Type': 'application/json', + }, + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + console.log('Social connections status:', data); + setSocialConnections({ + google: data.google || false, + discord: data.discord || false, + }); + } else { + console.error('Failed to fetch social connections:', { + status: response.status, + statusText: response.statusText + }); + + try { + const errorData = await response.text(); + console.error('API error response:', errorData); + } catch (e) { + console.error('Could not parse error response'); + } + } + } catch (error) { + console.error('Error fetching social connections:', error); + } finally { + setIsFetchingSocial(false); + } + }; + + const showTemporaryMessage = (message: string, isError: boolean = false) => { + if (isError) { + setError(message); + setTimeout(() => setError(null), 5000); // 5 segundos + } else { + setSuccess(message); + setTimeout(() => setSuccess(null), 5000); // 5 segundos + } + }; + + useEffect(() => { + const currentToken = accessKey || getCookie("access_key"); + + const code = searchParams?.get('code'); + const state = searchParams?.get('state'); + + let socialType: 'google' | 'discord' | null = null; + if (state) { + if (state.includes('google')) { + socialType = 'google'; + } else if (state.includes('discord')) { + socialType = 'discord'; + } + } + else if (code && typeof window !== 'undefined') { + const referrer = document.referrer || ''; + if (referrer.includes('google') || referrer.includes('accounts.google.com')) { + socialType = 'google'; + } else if (referrer.includes('discord') || referrer.includes('discord.com')) { + socialType = 'discord'; + } + + if (!socialType) { + const params = new URLSearchParams(window.location.search); + if (params.has('guild_id') || params.has('permissions')) { + socialType = 'discord'; + } else { + socialType = 'google'; + } + } + } + + console.log('OAuth callback detected:', { code: code ? 'present' : 'absent', state, socialType }); + + if (code && socialType && currentToken) { + const completeOAuthLink = async () => { + try { + setIsFetchingSocial(true); + + console.log(`Completing ${socialType} OAuth flow with code`); + + console.log(`Sending OAuth2 code completion for ${socialType}:`, { + url: `${apiUrl}/v1/users/me/login/social?action=link&type=${socialType}&code=...`, + token: currentToken ? `${currentToken.substring(0, 10)}...` : 'missing', + codeLength: code.length + }); + + const response = await fetch(`${apiUrl}/v1/users/me/login/social?action=link&type=${socialType}&code=${encodeURIComponent(code)}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${currentToken}`, + 'Content-Type': 'application/json', + }, + credentials: 'include' + }); + + const responseDebug = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + type: response.type, + url: response.url, + redirected: response.redirected + }; + + console.log(`${socialType} link completion response:`, responseDebug); + + if (response.ok) { + const data = await response.json(); + console.log(`${socialType} link completion data:`, data); + + if (data.result === 'success') { + showTemporaryMessage(data.message || `Conta ${socialType === 'google' ? 'Google' : 'Discord'} vinculada com sucesso!`); + setSocialConnections(prev => ({...prev, [socialType]: true})); + + console.log(`${socialType} account linked successfully:`, { + user: data.user || 'No user data returned' + }); + + fetchSocialConnections(); + } else { + showTemporaryMessage(`Erro ao vincular conta ${socialType === 'google' ? 'Google' : 'Discord'}`, true); + } + } else { + try { + const errorData = await response.json(); + console.error(`API error response:`, errorData); + showTemporaryMessage(errorData.error || `Erro ao vincular conta ${socialType === 'google' ? 'Google' : 'Discord'}`, true); + } catch (e) { + console.error('Could not parse error response'); + showTemporaryMessage(`Erro ao vincular conta ${socialType === 'google' ? 'Google' : 'Discord'} (${response.status})`, true); + } + } + + if (typeof window !== 'undefined') { + window.history.replaceState({}, document.title, '/dashboard/settings'); + } + } catch (error) { + console.error(`Error completing ${socialType} connection:`, error); + showTemporaryMessage(`Erro ao vincular conta ${socialType === 'google' ? 'Google' : 'Discord'}`, true); + } finally { + setIsFetchingSocial(false); + } + }; + + completeOAuthLink(); + } else { + const socialLink = searchParams?.get('social_link'); + if (socialLink) { + if (socialLink === 'google_success') { + showTemporaryMessage('Conta Google vinculada com sucesso!'); + if (typeof window !== 'undefined') { + window.history.replaceState({}, document.title, '/dashboard/settings'); + } + } else if (socialLink === 'discord_success') { + showTemporaryMessage('Conta Discord vinculada com sucesso!'); + if (typeof window !== 'undefined') { + window.history.replaceState({}, document.title, '/dashboard/settings'); + } + } + } + + if (currentToken) { + fetchSocialConnections(); + } else { + setIsFetchingSocial(false); + showTemporaryMessage("Não foi possível obter suas conexões sociais. Verifique se está logado.", true); + } + } + }, [searchParams, apiUrl, accessKey]); const handlePasswordChange = (field: string, value: string) => { setPasswordData((prev) => ({ ...prev, [field]: value })); @@ -36,25 +249,32 @@ export default function SecurityContent() { const handleChangePassword = async () => { if (passwordData.new !== passwordData.confirm) { - setError("As senhas não coincidem!"); + showTemporaryMessage("As senhas não coincidem!", true); return; } if (passwordData.new.length < 6) { - setError("A senha deve ter pelo menos 6 caracteres!"); + showTemporaryMessage("A senha deve ter pelo menos 6 caracteres!", true); return; } try { + const currentToken = accessKey || getCookie("access_key"); + if (!currentToken) { + showTemporaryMessage("Sessão expirada. Faça login novamente.", true); + return; + } + setIsLoading(true); setError(null); const response = await fetch(`${apiUrl}/v1/users/me?action=UpdatePassword`, { method: 'POST', headers: { - 'Authorization': `Bearer ${accessKey}`, + 'Authorization': `Bearer ${currentToken}`, 'Content-Type': 'application/json', }, + credentials: 'include', body: JSON.stringify({ password: passwordData.new }), @@ -62,14 +282,11 @@ export default function SecurityContent() { if (response.ok) { const data = await response.json(); - setSuccess('Senha alterada com sucesso!'); + showTemporaryMessage('Senha alterada com sucesso!'); setPasswordData({ new: "", confirm: "" }); - - // Limpar mensagem de sucesso após 3 segundos - setTimeout(() => setSuccess(null), 3000); } else { const errorData = await response.json(); - setError(errorData.error || 'Erro ao alterar senha'); + showTemporaryMessage(errorData.error || 'Erro ao alterar senha', true); } } catch (error) { console.error('Error changing password:', error); @@ -78,9 +295,74 @@ export default function SecurityContent() { setIsLoading(false); } }; + + const handleConnectSocial = async (type: 'google' | 'discord') => { + const currentToken = accessKey || getCookie("access_key"); + if (!currentToken) { + showTemporaryMessage("Sessão expirada. Faça login novamente.", true); + return; + } + + setLoadingSocial(prev => ({...prev, [type]: true})); + try { + if (type === 'google') { + window.location.href = '/api/auth/google-link'; + } else if (type === 'discord') { + window.location.href = '/api/auth/discord-link'; + } + } catch (error) { + console.error(`Error connecting ${type} account:`, error); + showTemporaryMessage(`Erro ao vincular conta ${type === 'google' ? 'Google' : 'Discord'}`, true); + setLoadingSocial(prev => ({...prev, [type]: false})); + } + }; + + const handleDisconnectSocial = async (type: 'google' | 'discord') => { + const currentToken = accessKey || getCookie("access_key"); + if (!currentToken) { + showTemporaryMessage("Sessão expirada. Faça login novamente.", true); + return; + } + + setLoadingSocial(prev => ({...prev, [type]: true})); + try { + const response = await fetch(`${apiUrl}/v1/users/me/login/social?action=unlink&type=${type}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${currentToken}`, + 'Content-Type': 'application/json', + }, + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + if (data.result === 'success') { + showTemporaryMessage(data.message || `Conta ${type === 'google' ? 'Google' : 'Discord'} desvinculada com sucesso!`); + setSocialConnections(prev => ({...prev, [type]: false})); + + fetchSocialConnections(); + } else { + showTemporaryMessage(`Erro ao desvincular conta ${type === 'google' ? 'Google' : 'Discord'}`, true); + } + } else { + try { + const errorData = await response.json(); + showTemporaryMessage(errorData.error || `Erro ao desvincular conta ${type === 'google' ? 'Google' : 'Discord'}`, true); + } catch (e) { + showTemporaryMessage(`Erro ao desvincular conta ${type === 'google' ? 'Google' : 'Discord'} (${response.status})`, true); + } + } + } catch (error) { + console.error(`Error disconnecting ${type} account:`, error); + setError(`Erro ao desvincular conta ${type === 'google' ? 'Google' : 'Discord'}`); + } finally { + setLoadingSocial(prev => ({...prev, [type]: false})); + } + }; + return (
- {/* Messages */} {error && (

{error}

@@ -93,7 +375,6 @@ export default function SecurityContent() {
)} - {/* Change Password */}
@@ -181,26 +462,101 @@ export default function SecurityContent() {
-
-
-
- - Discord -
- + {isFetchingSocial ? ( +
+
-
-
- - Google + ) : ( +
+ {/* Discord Connection */} +
+
+ +
+ Discord + {socialConnections.discord && ( +
+ + Conectado +
+ )} +
+
+ + {socialConnections.discord ? ( + + ) : ( + + )} +
+ + {/* Google Connection */} +
+
+ +
+ Google + {socialConnections.google && ( +
+ + Conectado +
+ )} +
+
+ + {socialConnections.google ? ( + + ) : ( + + )}
-
-
+ )}
); diff --git a/src/pages/api/auth/discord-link.ts b/src/pages/api/auth/discord-link.ts new file mode 100644 index 0000000..50637de --- /dev/null +++ b/src/pages/api/auth/discord-link.ts @@ -0,0 +1,63 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getCookie } from "@/utils/cookies"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + // Obter o token de acesso dos cookies + const accessToken = req.cookies["access_key"]; + + if (!accessToken) { + return res.status(401).json({ error: "Não autenticado" }); + } + + // Chamar a API para iniciar o fluxo de vinculação do Discord + const apiRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/users/me/login/social?action=link&type=discord`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}` + } + }); + + // Verificar se recebemos um redirecionamento + if (apiRes.redirected) { + return res.redirect(apiRes.url); + } + + // Verificar se recebemos um código 302 e extrair a URL de redirecionamento + if (apiRes.status === 302) { + const location = apiRes.headers.get("Location"); + if (location) return res.redirect(location); + } + + // Caso o resultado seja um JSON com URL + try { + const data = await apiRes.json(); + if (data && data.redirectUrl) { + return res.redirect(data.redirectUrl); + } + if (data && data.url) { + return res.redirect(data.url); + } + } catch (e) { + // Se não for JSON, pode ser texto direto + try { + const text = await apiRes.text(); + if (text && text.trim().startsWith('http')) { + return res.redirect(text.trim()); + } + } catch (e2) { + console.error("Erro ao ler resposta como texto:", e2); + } + } + + return res.status(400).json({ + error: "URL de redirecionamento não recebida", + status: apiRes.status, + statusText: apiRes.statusText + }); + } catch (e) { + console.error("Erro ao redirecionar para o Discord:", e); + return res.status(500).json({ error: "Erro ao redirecionar para o Discord" }); + } +} diff --git a/src/pages/api/auth/google-link.ts b/src/pages/api/auth/google-link.ts new file mode 100644 index 0000000..4af0797 --- /dev/null +++ b/src/pages/api/auth/google-link.ts @@ -0,0 +1,62 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + // Obter o token de acesso dos cookies + const accessToken = req.cookies["access_key"]; + + if (!accessToken) { + return res.status(401).json({ error: "Não autenticado" }); + } + + // Chamar a API para iniciar o fluxo de vinculação do Google + const apiRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/users/me/login/social?action=link&type=google`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}` + } + }); + + // Verificar se recebemos um redirecionamento + if (apiRes.redirected) { + return res.redirect(apiRes.url); + } + + // Verificar se recebemos um código 302 e extrair a URL de redirecionamento + if (apiRes.status === 302) { + const location = apiRes.headers.get("Location"); + if (location) return res.redirect(location); + } + + // Caso o resultado seja um JSON com URL + try { + const data = await apiRes.json(); + if (data && data.redirectUrl) { + return res.redirect(data.redirectUrl); + } + if (data && data.url) { + return res.redirect(data.url); + } + } catch (e) { + // Se não for JSON, pode ser texto direto + try { + const text = await apiRes.text(); + if (text && text.trim().startsWith('http')) { + return res.redirect(text.trim()); + } + } catch (e2) { + console.error("Erro ao ler resposta como texto:", e2); + } + } + + return res.status(400).json({ + error: "URL de redirecionamento não recebida", + status: apiRes.status, + statusText: apiRes.statusText + }); + } catch (e) { + console.error("Erro ao redirecionar para o Google:", e); + return res.status(500).json({ error: "Erro ao redirecionar para o Google" }); + } +}