diff --git a/src/components/AdminWalletsModal.js b/src/components/AdminWalletsModal.js new file mode 100644 index 0000000..d166c61 --- /dev/null +++ b/src/components/AdminWalletsModal.js @@ -0,0 +1,327 @@ +import React, { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; +import { + XCircleIcon, + PlusIcon, + TrashIcon, + ShieldCheckIcon, + UserCircleIcon, +} from '@heroicons/react/24/outline'; + +const AdminWalletsModal = ({ + isOpen, + onClose, + getAdminWallets, + addAdminWallet, + deleteAdminWallet, + hasPermission, + showToast, +}) => { + const { t } = useTranslation(); + const [wallets, setWallets] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [error, setError] = useState(null); + + // Form state for adding new wallet + const [showAddForm, setShowAddForm] = useState(false); + const [newWallet, setNewWallet] = useState({ + wallet: '', + name: '', + role: 'admin', + }); + + // Load wallets on mount + useEffect(() => { + if (isOpen) { + loadWallets(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const loadWallets = async () => { + setLoading(true); + setError(null); + try { + const response = await getAdminWallets(); + setWallets(response.data || response || []); + } catch (err) { + setError(err.message || t('admin.error_loading_wallets') || 'Error loading wallets'); + } finally { + setLoading(false); + } + }; + + const handleAddWallet = async (e) => { + e.preventDefault(); + if (!newWallet.wallet.trim()) return; + + // Validate wallet address format + if (!/^0x[a-fA-F0-9]{40}$/.test(newWallet.wallet.trim())) { + showToast.error(t('admin.invalid_wallet_format') || 'Invalid wallet address format'); + return; + } + + setActionLoading(true); + try { + await addAdminWallet({ + wallet: newWallet.wallet.trim().toLowerCase(), + name: newWallet.name.trim() || undefined, + role: newWallet.role, + }); + showToast.success(t('admin.wallet_added') || 'Wallet added successfully'); + setNewWallet({ wallet: '', name: '', role: 'admin' }); + setShowAddForm(false); + await loadWallets(); + } catch (err) { + showToast.error(err.message || t('admin.error_adding_wallet') || 'Error adding wallet'); + } finally { + setActionLoading(false); + } + }; + + const handleDeleteWallet = async (walletId, walletAddress) => { + if (!window.confirm( + t('admin.confirm_delete_wallet', { wallet: walletAddress }) || + `Are you sure you want to remove ${walletAddress} as admin?` + )) { + return; + } + + setActionLoading(true); + try { + await deleteAdminWallet(walletId); + showToast.success(t('admin.wallet_deleted') || 'Wallet removed successfully'); + await loadWallets(); + } catch (err) { + showToast.error(err.message || t('admin.error_deleting_wallet') || 'Error removing wallet'); + } finally { + setActionLoading(false); + } + }; + + const formatAddress = (addr) => { + if (!addr) return ''; + return addr.slice(0, 6) + '...' + addr.slice(-4); + }; + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > + {/* Header */} +
+
+

+ + {t('admin.manage_wallets') || 'Manage Admin Wallets'} +

+ +
+

+ {t('admin.manage_wallets_desc') || 'Add or remove wallets that can manage bounties'} +

+
+ + {/* Content */} +
+ {loading ? ( +
+
+

{t('common.loading') || 'Loading...'}

+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : ( + <> + {/* Wallets List */} +
+ {wallets.length === 0 ? ( +
+ +

{t('admin.no_wallets') || 'No admin wallets configured'}

+
+ ) : ( + wallets.map((wallet) => ( +
+
+
+ +
+
+
+ + {formatAddress(wallet.wallet)} + + + {wallet.role === 'superadmin' ? 'Super Admin' : 'Admin'} + + {wallet.isInitial && ( + + {t('admin.protected') || 'Protected'} + + )} +
+ {wallet.name && ( +

{wallet.name}

+ )} +
+
+ + {/* Delete button - only if not initial/protected */} + {!wallet.isInitial && hasPermission('manage_admins') && ( + + )} +
+ )) + )} +
+ + {/* Add Wallet Form */} + {hasPermission('manage_admins') && ( + <> + {!showAddForm ? ( + + ) : ( + +
+ + setNewWallet({ ...newWallet, wallet: e.target.value })} + placeholder="0x..." + className="w-full px-4 py-2 rounded-lg border border-ultraviolet-darker/20 bg-background-lighter focus:border-ultraviolet focus:ring-1 focus:ring-ultraviolet outline-none text-text-primary font-mono text-sm" + required + /> +
+ +
+ + setNewWallet({ ...newWallet, name: e.target.value })} + placeholder="e.g., John Doe" + className="w-full px-4 py-2 rounded-lg border border-ultraviolet-darker/20 bg-background-lighter focus:border-ultraviolet focus:ring-1 focus:ring-ultraviolet outline-none text-text-primary text-sm" + /> +
+ +
+ + +
+ +
+ + +
+
+ )} + + )} + + )} +
+
+
+ ); +}; + +export default AdminWalletsModal; diff --git a/src/components/BountyForm.js b/src/components/BountyForm.js index aa6da81..947bac7 100644 --- a/src/components/BountyForm.js +++ b/src/components/BountyForm.js @@ -1,27 +1,64 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; + +// Tokens disponibles para recompensas (el label de CUSTOM se traduce dinámicamente) +const REWARD_TOKENS = [ + { symbol: 'USDC', label: 'USDC', icon: '💵' }, + { symbol: 'UVD', label: '$UVD', icon: '🟣' }, + { symbol: 'AVAX', label: 'AVAX', icon: '🔺' }, + { symbol: 'POL', label: 'POL', icon: '🟪' }, + { symbol: 'SOL', label: 'SOL', icon: '◎' }, + { symbol: 'ETH', label: 'ETH', icon: 'Ξ' }, + { symbol: 'CUSTOM', labelKey: 'bountyForm.custom_token_label', icon: '✏️' }, +]; const BountyForm = ({ onSubmit, loading, error }) => { const { t } = useTranslation(); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); - const [reward, setReward] = useState(''); + const [rewardAmount, setRewardAmount] = useState(''); + const [selectedToken, setSelectedToken] = useState(REWARD_TOKENS[0]); // USDC por defecto + const [customToken, setCustomToken] = useState(''); + const [showTokenDropdown, setShowTokenDropdown] = useState(false); const [endDate, setEndDate] = useState(''); + const dropdownRef = useRef(null); + + // Cerrar dropdown al hacer clic fuera + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowTokenDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); const resetForm = () => { setTitle(''); setDescription(''); - setReward(''); + setRewardAmount(''); + setSelectedToken(REWARD_TOKENS[0]); + setCustomToken(''); setEndDate(''); }; + // Construir el reward string completo (ej: "100 USDC" o "50 UVD") + const getRewardString = () => { + const amount = rewardAmount.trim(); + if (!amount) return ''; + const tokenSymbol = selectedToken.symbol === 'CUSTOM' ? customToken.trim() : selectedToken.symbol; + return tokenSymbol ? `${amount} ${tokenSymbol}` : amount; + }; + const handleSubmit = async (e) => { e.preventDefault(); try { await onSubmit({ title, description, - reward, + reward: getRewardString(), endDate: endDate || null, }); // Reset form on successful submission @@ -31,6 +68,14 @@ const BountyForm = ({ onSubmit, loading, error }) => { } }; + const handleTokenSelect = (token) => { + setSelectedToken(token); + setShowTokenDropdown(false); + if (token.symbol !== 'CUSTOM') { + setCustomToken(''); + } + }; + return (
@@ -56,14 +101,67 @@ const BountyForm = ({ onSubmit, loading, error }) => {
- setReward(e.target.value)} - required - /> +
+ {/* Input de cantidad */} + setRewardAmount(e.target.value)} + required + /> + + {/* Selector de token */} +
+ + + {/* Dropdown menu */} + {showTokenDropdown && ( +
+ {REWARD_TOKENS.map((token) => ( + + ))} +
+ )} +
+
+ + {/* Input para token personalizado */} + {selectedToken.symbol === 'CUSTOM' && ( + setCustomToken(e.target.value.toUpperCase())} + maxLength={10} + required + /> + )}
diff --git a/src/components/SubmissionList.js b/src/components/SubmissionList.js index d186a07..a658a12 100644 --- a/src/components/SubmissionList.js +++ b/src/components/SubmissionList.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import Modal from './Modal'; import { bountiesAPI } from '../services/api'; +import { LinkifyText } from '../utils/linkify'; const SubmissionList = ({ bountyId }) => { const { t } = useTranslation(); @@ -9,8 +10,6 @@ const SubmissionList = ({ bountyId }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [profileMap, setProfileMap] = useState({}); // wallet -> { name, avatar } - const [account, setAccount] = useState(null); - const [userProfile, setUserProfile] = useState(null); const [showModal, setShowModal] = useState(false); const [modalContent, setModalContent] = useState(null); @@ -54,15 +53,6 @@ const SubmissionList = ({ bountyId }) => { return {}; }; - // Obtener la wallet conectada desde localStorage (igual que Snapshot) - useEffect(() => { - const savedAddress = localStorage.getItem('walletAddress'); - if (savedAddress) { - setAccount(savedAddress); - fetchSnapshotProfile(savedAddress).then(setUserProfile); - } - }, []); - // Fetch de submissions por bountyId useEffect(() => { const fetchSubmissions = async () => { @@ -118,8 +108,13 @@ const SubmissionList = ({ bountyId }) => { onError={e => { e.target.onerror = null; e.target.src = submission.submitterName ? `https://effigy.im/a/${submission.submitterName}.svg` : ''; }} /> )} -
-

{submission.submissionContent}

+
+ + {submission.submissionContent} +

{t('submissionList.submitted_by')} {displayName}

@@ -142,9 +137,12 @@ const SubmissionList = ({ bountyId }) => { )} {modalContent?.displayName}
-
+ {modalContent?.content} -
+ )}
diff --git a/src/components/WalletConnect.js b/src/components/WalletConnect.js index 14d2eca..469a9dc 100644 --- a/src/components/WalletConnect.js +++ b/src/components/WalletConnect.js @@ -407,15 +407,28 @@ const WalletConnect = ({ onWalletConnected, onWalletDisconnected }) => { * Renderiza el icono de la wallet (soporta SVG de EIP-6963 o emoji) */ const renderWalletIcon = (icon) => { - if (!icon) return '🔐'; - - // Si es un data URL (SVG de EIP-6963) o URL de imagen - if (icon.startsWith('data:') || icon.startsWith('http')) { - return ; + if (!icon) return 🔐; + + // Si es un data URL (SVG/PNG de EIP-6963) o URL de imagen + if (typeof icon === 'string' && (icon.startsWith('data:') || icon.startsWith('http'))) { + return ( +
+ { + // Fallback si la imagen falla al cargar + e.target.style.display = 'none'; + e.target.parentNode.innerHTML = '🔐'; + }} + /> +
+ ); } - // Si es emoji - return {icon}; + // Si es emoji u otro string corto + return {icon}; }; return ( @@ -438,10 +451,10 @@ const WalletConnect = ({ onWalletConnected, onWalletDisconnected }) => { disabled={isConnecting} className="w-full flex items-center gap-3 p-3 rounded-lg bg-background border border-ultraviolet-darker/10 hover:border-ultraviolet-darker/40 hover:bg-ultraviolet-darker/5 transition-all - disabled:opacity-50 disabled:cursor-not-allowed" + disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden" > {renderWalletIcon(wallet.icon)} - {wallet.name} + {wallet.name} ))}
diff --git a/src/hooks/index.js b/src/hooks/index.js index 53d036e..ebbc3cc 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,2 +1,3 @@ export { default as useBounties } from './useBounties'; export { default as useWhitelist } from './useWhitelist'; +export { default as useAdmin } from './useAdmin'; diff --git a/src/hooks/useAdmin.js b/src/hooks/useAdmin.js new file mode 100644 index 0000000..d92cc72 --- /dev/null +++ b/src/hooks/useAdmin.js @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '../services/api'; + +const ADMIN_TOKEN_KEY = 'uvd_admin_token'; +const ADMIN_DATA_KEY = 'uvd_admin_data'; + +/** + * Hook para manejar la autenticación y funcionalidad de admin + */ +const useAdmin = (walletAddress, provider) => { + const [isAdmin, setIsAdmin] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [adminData, setAdminData] = useState(null); + const [loading, setLoading] = useState(true); + const [authLoading, setAuthLoading] = useState(false); + const [error, setError] = useState(null); + + // Verificar si la wallet es admin + const checkIsAdmin = useCallback(async (wallet) => { + if (!wallet) { + setIsAdmin(false); + setLoading(false); + return false; + } + + try { + const response = await adminAPI.isAdmin(wallet); + setIsAdmin(response.isAdmin); + return response.isAdmin; + } catch (err) { + console.error('Error checking admin status:', err); + setIsAdmin(false); + return false; + } finally { + setLoading(false); + } + }, []); + + // Verificar si hay un token válido guardado + const checkStoredAuth = useCallback(async () => { + const storedToken = localStorage.getItem(ADMIN_TOKEN_KEY); + const storedData = localStorage.getItem(ADMIN_DATA_KEY); + + if (!storedToken || !storedData) { + setIsAuthenticated(false); + return false; + } + + try { + const data = JSON.parse(storedData); + + // Verificar que la wallet del token coincida con la conectada + if (walletAddress && data.wallet !== walletAddress.toLowerCase()) { + // Wallet diferente, limpiar auth + localStorage.removeItem(ADMIN_TOKEN_KEY); + localStorage.removeItem(ADMIN_DATA_KEY); + setIsAuthenticated(false); + setAdminData(null); + return false; + } + + // Verificar token con el backend + await adminAPI.checkAuth(storedToken); + setIsAuthenticated(true); + setAdminData(data); + return true; + } catch (err) { + // Token inválido o expirado + localStorage.removeItem(ADMIN_TOKEN_KEY); + localStorage.removeItem(ADMIN_DATA_KEY); + setIsAuthenticated(false); + setAdminData(null); + return false; + } + }, [walletAddress]); + + // Autenticarse como admin (firma de mensaje) + const authenticate = useCallback(async () => { + console.log('[useAdmin] authenticate called', { walletAddress, hasProvider: !!provider }); + + if (!walletAddress || !provider) { + console.error('[useAdmin] Missing wallet or provider', { walletAddress, provider }); + setError('Wallet no conectada'); + return false; + } + + setAuthLoading(true); + setError(null); + + try { + // 1. Obtener nonce para firmar + console.log('[useAdmin] Getting nonce...'); + const nonceResponse = await adminAPI.getNonce(walletAddress); + const { message } = nonceResponse; + console.log('[useAdmin] Got nonce, message length:', message?.length); + + // 2. Solicitar firma al usuario + console.log('[useAdmin] Getting signer...'); + // Usar el provider que ya está conectado (no window.ethereum que puede ser otra wallet) + const signer = await provider.getSigner(walletAddress); + console.log('[useAdmin] Requesting signature...'); + const signature = await signer.signMessage(message); + console.log('[useAdmin] Got signature'); + + // 3. Verificar firma y obtener JWT + console.log('[useAdmin] Verifying signature...'); + const verifyResponse = await adminAPI.verifySignature(walletAddress, signature, message); + const { token, admin } = verifyResponse; + console.log('[useAdmin] Verified, got token'); + + // 4. Guardar token y datos + localStorage.setItem(ADMIN_TOKEN_KEY, token); + localStorage.setItem(ADMIN_DATA_KEY, JSON.stringify(admin)); + + setIsAuthenticated(true); + setAdminData(admin); + return true; + } catch (err) { + console.error('[useAdmin] Authentication error:', err); + setError(err.message || 'Error de autenticación'); + return false; + } finally { + setAuthLoading(false); + } + }, [walletAddress, provider]); + + // Cerrar sesión de admin + const logout = useCallback(() => { + localStorage.removeItem(ADMIN_TOKEN_KEY); + localStorage.removeItem(ADMIN_DATA_KEY); + setIsAuthenticated(false); + setAdminData(null); + }, []); + + // Obtener token actual + const getToken = useCallback(() => { + return localStorage.getItem(ADMIN_TOKEN_KEY); + }, []); + + // ==================== OPERACIONES DE ADMIN ==================== + + // Actualizar bounty + const updateBounty = useCallback(async (bountyId, data) => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.updateBounty(bountyId, data, token); + }, [getToken]); + + // Eliminar bounty + const deleteBounty = useCallback(async (bountyId) => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.deleteBounty(bountyId, token); + }, [getToken]); + + // Cambiar estado de bounty + const updateBountyStatus = useCallback(async (bountyId, status) => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.updateBountyStatus(bountyId, status, token); + }, [getToken]); + + // ==================== GESTIÓN DE WALLETS ADMIN ==================== + + // Obtener lista de wallets admin + const getAdminWallets = useCallback(async () => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.getAdminWallets(token); + }, [getToken]); + + // Agregar wallet admin + const addAdminWallet = useCallback(async (walletData) => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.addAdminWallet(walletData, token); + }, [getToken]); + + // Actualizar wallet admin + const updateAdminWallet = useCallback(async (walletId, data) => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.updateAdminWallet(walletId, data, token); + }, [getToken]); + + // Eliminar wallet admin + const deleteAdminWallet = useCallback(async (walletId) => { + const token = getToken(); + if (!token) { + throw new Error('No autenticado como admin'); + } + return adminAPI.deleteAdminWallet(walletId, token); + }, [getToken]); + + // Verificar permisos + const hasPermission = useCallback((permission) => { + if (!adminData) return false; + if (adminData.role === 'superadmin') return true; + return adminData.permissions?.includes(permission) || false; + }, [adminData]); + + // Efectos + useEffect(() => { + if (walletAddress) { + checkIsAdmin(walletAddress); + checkStoredAuth(); + } else { + setIsAdmin(false); + setIsAuthenticated(false); + setAdminData(null); + setLoading(false); + } + }, [walletAddress, checkIsAdmin, checkStoredAuth]); + + // Limpiar auth si cambia la wallet + useEffect(() => { + const storedData = localStorage.getItem(ADMIN_DATA_KEY); + if (storedData) { + try { + const data = JSON.parse(storedData); + if (walletAddress && data.wallet !== walletAddress.toLowerCase()) { + logout(); + } + } catch { + logout(); + } + } + }, [walletAddress, logout]); + + return { + // Estado + isAdmin, + isAuthenticated, + adminData, + loading, + authLoading, + error, + + // Autenticación + authenticate, + logout, + getToken, + + // Operaciones de bounties + updateBounty, + deleteBounty, + updateBountyStatus, + + // Gestión de wallets admin + getAdminWallets, + addAdminWallet, + updateAdminWallet, + deleteAdminWallet, + + // Utilidades + hasPermission, + checkIsAdmin, + }; +}; + +export default useAdmin; diff --git a/src/hooks/useBounties.js b/src/hooks/useBounties.js index f6bf71a..6baa273 100644 --- a/src/hooks/useBounties.js +++ b/src/hooks/useBounties.js @@ -29,7 +29,12 @@ const useBounties = () => { setFetchBountiesError(null); try { const data = await bountiesAPI.getAll(); - setTasks(data.data || data); + const allBounties = data.data || data; + // Filtrar bounties cancelados e inactivos + const activeBounties = allBounties.filter( + (b) => b.isActive !== false && b.status !== 'cancelled' + ); + setTasks(activeBounties); } catch (err) { setFetchBountiesError(err.message); } finally { diff --git a/src/i18n/en.json b/src/i18n/en.json index 63809b8..ea66e58 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -690,6 +690,8 @@ }, "common": { "cancel": "Cancel", + "save": "Save", + "confirm": "Confirm", "anonymous": "Anonymous", "disconnect": "Disconnect", "locale": "en_US", @@ -2439,11 +2441,13 @@ "bountyForm": { "title_label": "Title", "description_label": "Description", - "reward_label": "Reward (e.g. 100 UVD)", + "reward_label": "Reward", "endDate_label": "End Date (optional)", "creating_button": "Creating...", "create_button": "Create Bounty", - "success_message": "Bounty \"{{title}}\" created successfully!" + "success_message": "Bounty \"{{title}}\" created successfully!", + "custom_token_placeholder": "Enter token symbol", + "custom_token_label": "Custom" }, "submissionList": { "loading": "Loading submissions...", @@ -2455,5 +2459,42 @@ "adminPanel": { "title": "Admin Panel", "description": "Create and manage community bounties" + }, + "admin": { + "login": "Admin", + "logout": "Admin ✓", + "connect_wallet_first": "Connect your wallet first", + "auth_success": "Authenticated as administrator", + "auth_failed": "Authentication error", + "auth_required": "You must authenticate as admin", + "status_updated": "Status updated successfully", + "status_error": "Error changing status", + "confirm_delete": "Are you sure you want to delete this bounty? This action cannot be undone.", + "confirm_delete_title": "Delete bounty?", + "bounty_deleted": "Bounty deleted successfully", + "delete_error": "Error deleting bounty", + "edit_bounty": "Edit Bounty", + "bounty_updated": "Bounty updated successfully", + "update_error": "Error updating bounty", + "edit": "Edit", + "delete": "Delete", + "manage_wallets": "Manage Admin Wallets", + "manage_wallets_desc": "Add or remove wallets that can manage bounties", + "no_wallets": "No admin wallets configured", + "add_wallet": "Add Admin Wallet", + "wallet_address": "Wallet Address", + "wallet_name": "Name (optional)", + "wallet_role": "Role", + "add": "Add", + "protected": "Protected", + "remove_wallet": "Remove wallet", + "wallet_added": "Wallet added successfully", + "wallet_deleted": "Wallet removed successfully", + "error_loading_wallets": "Error loading wallets", + "error_adding_wallet": "Error adding wallet", + "error_deleting_wallet": "Error removing wallet", + "invalid_wallet_format": "Invalid wallet address format", + "confirm_delete_wallet": "Are you sure you want to remove {{wallet}} as admin?", + "reconnect_wallet": "Please reconnect your wallet to sign" } } diff --git a/src/i18n/es.json b/src/i18n/es.json index 2cba648..48bd801 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -597,6 +597,8 @@ }, "common": { "cancel": "Cancelar", + "save": "Guardar", + "confirm": "Confirmar", "anonymous": "Anónimo", "disconnect": "Desconectar", "locale": "es_ES", @@ -2104,11 +2106,13 @@ "bountyForm": { "title_label": "Título", "description_label": "Descripción", - "reward_label": "Recompensa (ej. 100 UVD)", + "reward_label": "Recompensa", "endDate_label": "Fecha de Fin (opcional)", "creating_button": "Creando...", "create_button": "Crear Bounty", - "success_message": "¡Bounty \"{{title}}\" creada exitosamente!" + "success_message": "¡Bounty \"{{title}}\" creada exitosamente!", + "custom_token_placeholder": "Escribe el símbolo del token", + "custom_token_label": "Personalizado" }, "submissionList": { "loading": "Cargando entregas...", @@ -2120,5 +2124,42 @@ "adminPanel": { "title": "Panel de Administración", "description": "Crear y gestionar bounties comunitarias" + }, + "admin": { + "login": "Admin", + "logout": "Admin ✓", + "connect_wallet_first": "Conecta tu wallet primero", + "auth_success": "Autenticado como administrador", + "auth_failed": "Error de autenticación", + "auth_required": "Debes autenticarte como admin", + "status_updated": "Estado actualizado correctamente", + "status_error": "Error al cambiar estado", + "confirm_delete": "¿Estás seguro de eliminar este bounty? Esta acción no se puede deshacer.", + "confirm_delete_title": "¿Eliminar bounty?", + "bounty_deleted": "Bounty eliminado correctamente", + "delete_error": "Error al eliminar bounty", + "edit_bounty": "Editar Bounty", + "bounty_updated": "Bounty actualizado correctamente", + "update_error": "Error al actualizar bounty", + "edit": "Editar", + "delete": "Eliminar", + "manage_wallets": "Gestionar Wallets Admin", + "manage_wallets_desc": "Agregar o remover wallets que pueden gestionar bounties", + "no_wallets": "No hay wallets admin configuradas", + "add_wallet": "Agregar Wallet Admin", + "wallet_address": "Dirección de Wallet", + "wallet_name": "Nombre (opcional)", + "wallet_role": "Rol", + "add": "Agregar", + "protected": "Protegida", + "remove_wallet": "Remover wallet", + "wallet_added": "Wallet agregada correctamente", + "wallet_deleted": "Wallet removida correctamente", + "error_loading_wallets": "Error cargando wallets", + "error_adding_wallet": "Error al agregar wallet", + "error_deleting_wallet": "Error al remover wallet", + "invalid_wallet_format": "Formato de dirección de wallet inválido", + "confirm_delete_wallet": "¿Estás seguro de remover {{wallet}} como admin?", + "reconnect_wallet": "Por favor, reconecta tu wallet para firmar" } } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index b9eb489..1b25091 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -227,6 +227,8 @@ }, "common": { "cancel": "Annuler", + "save": "Enregistrer", + "confirm": "Confirmer", "anonymous": "Anonyme", "disconnect": "Déconnecter" }, @@ -255,7 +257,9 @@ "create_button": "Créer Bounty", "creating_button": "Création...", "success_message": "Bounty \"{{title}}\" créé avec succès!", - "error_creating_bounty": "Erreur lors de la création du bounty. Veuillez vous connecter." + "error_creating_bounty": "Erreur lors de la création du bounty. Veuillez vous connecter.", + "custom_token_placeholder": "Entrez le symbole du token", + "custom_token_label": "Personnalisé" }, "adminPanel": { "title": "Panneau d'administration des Bounties", @@ -369,5 +373,42 @@ "no_submissions_to_vote": "Aucune soumission à voter", "connect_wallet_to_vote": "Connecter Wallet pour Voter", "wallet_required_message": "Vous devez connecter votre wallet pour participer au vote décentralisé." + }, + "admin": { + "login": "Admin", + "logout": "Admin ✓", + "connect_wallet_first": "Connectez d'abord votre wallet", + "auth_success": "Authentifié en tant qu'administrateur", + "auth_failed": "Erreur d'authentification", + "auth_required": "Vous devez vous authentifier en tant qu'admin", + "status_updated": "Statut mis à jour avec succès", + "status_error": "Erreur lors du changement de statut", + "confirm_delete": "Êtes-vous sûr de vouloir supprimer ce bounty ? Cette action est irréversible.", + "confirm_delete_title": "Supprimer le bounty ?", + "bounty_deleted": "Bounty supprimé avec succès", + "delete_error": "Erreur lors de la suppression du bounty", + "edit_bounty": "Modifier Bounty", + "bounty_updated": "Bounty mis à jour avec succès", + "update_error": "Erreur lors de la mise à jour du bounty", + "edit": "Modifier", + "delete": "Supprimer", + "manage_wallets": "Gérer les Wallets Admin", + "manage_wallets_desc": "Ajouter ou supprimer des wallets pouvant gérer les bounties", + "no_wallets": "Aucun wallet admin configuré", + "add_wallet": "Ajouter un Wallet Admin", + "wallet_address": "Adresse du Wallet", + "wallet_name": "Nom (optionnel)", + "wallet_role": "Rôle", + "add": "Ajouter", + "protected": "Protégé", + "remove_wallet": "Supprimer wallet", + "wallet_added": "Wallet ajouté avec succès", + "wallet_deleted": "Wallet supprimé avec succès", + "error_loading_wallets": "Erreur lors du chargement des wallets", + "error_adding_wallet": "Erreur lors de l'ajout du wallet", + "error_deleting_wallet": "Erreur lors de la suppression du wallet", + "invalid_wallet_format": "Format d'adresse wallet invalide", + "confirm_delete_wallet": "Êtes-vous sûr de vouloir supprimer {{wallet}} en tant qu'admin ?", + "reconnect_wallet": "Veuillez reconnecter votre wallet pour signer" } } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 3cde692..75e3610 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -227,6 +227,8 @@ }, "common": { "cancel": "Cancelar", + "save": "Salvar", + "confirm": "Confirmar", "anonymous": "Anónimo", "disconnect": "Desconectar" }, @@ -255,7 +257,9 @@ "create_button": "Criar Bounty", "creating_button": "Criando...", "success_message": "Bounty \"{{title}}\" criado com sucesso!", - "error_creating_bounty": "Erro ao criar bounty. Por favor, faça login." + "error_creating_bounty": "Erro ao criar bounty. Por favor, faça login.", + "custom_token_placeholder": "Digite o símbolo do token", + "custom_token_label": "Personalizado" }, "adminPanel": { "title": "Painel de administração de Bounties", @@ -369,5 +373,42 @@ "no_submissions_to_vote": "Não há entregas para votar", "connect_wallet_to_vote": "Conectar Wallet para Votar", "wallet_required_message": "Você precisa conectar sua wallet para participar da votação descentralizada." + }, + "admin": { + "login": "Admin", + "logout": "Admin ✓", + "connect_wallet_first": "Conecte sua wallet primeiro", + "auth_success": "Autenticado como administrador", + "auth_failed": "Erro de autenticação", + "auth_required": "Você deve se autenticar como admin", + "status_updated": "Status atualizado com sucesso", + "status_error": "Erro ao alterar status", + "confirm_delete": "Tem certeza de que deseja excluir este bounty? Esta ação não pode ser desfeita.", + "confirm_delete_title": "Excluir bounty?", + "bounty_deleted": "Bounty excluído com sucesso", + "delete_error": "Erro ao excluir bounty", + "edit_bounty": "Editar Bounty", + "bounty_updated": "Bounty atualizado com sucesso", + "update_error": "Erro ao atualizar bounty", + "edit": "Editar", + "delete": "Excluir", + "manage_wallets": "Gerenciar Wallets Admin", + "manage_wallets_desc": "Adicionar ou remover wallets que podem gerenciar bounties", + "no_wallets": "Nenhuma wallet admin configurada", + "add_wallet": "Adicionar Wallet Admin", + "wallet_address": "Endereço da Wallet", + "wallet_name": "Nome (opcional)", + "wallet_role": "Função", + "add": "Adicionar", + "protected": "Protegida", + "remove_wallet": "Remover wallet", + "wallet_added": "Wallet adicionada com sucesso", + "wallet_deleted": "Wallet removida com sucesso", + "error_loading_wallets": "Erro ao carregar wallets", + "error_adding_wallet": "Erro ao adicionar wallet", + "error_deleting_wallet": "Erro ao remover wallet", + "invalid_wallet_format": "Formato de endereço de wallet inválido", + "confirm_delete_wallet": "Tem certeza de que deseja remover {{wallet}} como admin?", + "reconnect_wallet": "Por favor, reconecte sua wallet para assinar" } } diff --git a/src/pages/Bounties.js b/src/pages/Bounties.js index 817ef46..dde318a 100644 --- a/src/pages/Bounties.js +++ b/src/pages/Bounties.js @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { motion } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; -import { ArrowLeftIcon, GiftIcon, UsersIcon, BoltIcon, UserCircleIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; +import { ArrowLeftIcon, GiftIcon, UsersIcon, BoltIcon, UserCircleIcon, CheckCircleIcon, XCircleIcon, ChevronDownIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline'; import { useTranslation } from 'react-i18next'; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; @@ -9,8 +9,11 @@ import BountyForm from '../components/BountyForm'; import SubmissionList from '../components/SubmissionList'; import WalletConnect from '../components/WalletConnect'; import { ethers } from 'ethers'; -import { bountiesAPI, legacyAPI } from '../services/api'; -import { useBounties, useWhitelist } from '../hooks'; +import { bountiesAPI } from '../services/api'; +import { useBounties, useWhitelist, useAdmin } from '../hooks'; +import { LinkifyText } from '../utils/linkify'; +import { TrashIcon, PencilIcon, ShieldCheckIcon, UsersIcon as UserGroupIcon } from '@heroicons/react/24/outline'; +import AdminWalletsModal from '../components/AdminWalletsModal'; // Helper para mostrar notificaciones toast const showToast = { @@ -49,6 +52,17 @@ const showToast = { }, }; +// Tokens disponibles para recompensas (igual que en BountyForm) +const REWARD_TOKENS = [ + { symbol: 'USDC', label: 'USDC', icon: '💵' }, + { symbol: 'UVD', label: '$UVD', icon: '🟣' }, + { symbol: 'AVAX', label: 'AVAX', icon: '🔺' }, + { symbol: 'POL', label: 'POL', icon: '🟪' }, + { symbol: 'SOL', label: 'SOL', icon: '◎' }, + { symbol: 'ETH', label: 'ETH', icon: 'Ξ' }, + { symbol: 'CUSTOM', labelKey: 'bountyForm.custom_token_label', icon: '✏️' }, +]; + // Column keys for Kanban board (titles and descriptions come from i18n) const COLUMN_KEYS = ['todo', 'in_voting', 'finished']; const COLUMN_ICONS = { @@ -70,6 +84,7 @@ const Bounties = () => { closeBountyDetails, fetchBounties, getTasksByStatus, + isVotingExpired, } = useBounties(); const { @@ -81,8 +96,33 @@ const Bounties = () => { resetWhitelist, } = useWhitelist(); + // Estados para Web3 y votación (declarados antes de useAdmin) + const [walletConnected, setWalletConnected] = useState(false); + const [walletAddress, setWalletAddress] = useState(null); + const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); + const [isWalletHover, setIsWalletHover] = useState(false); + const [ensName, setEnsName] = useState(null); + const [web3Provider, setWeb3Provider] = useState(null); + + // Admin hook - se inicializa con null y se actualiza cuando se conecta la wallet + const [adminProvider, setAdminProvider] = useState(null); + const { + isAdmin, + isAuthenticated: isAdminAuthenticated, + authLoading: adminAuthLoading, + error: adminError, + authenticate: authenticateAdmin, + logout: logoutAdmin, + updateBounty: adminUpdateBounty, + deleteBounty: adminDeleteBounty, + updateBountyStatus: adminUpdateStatus, + hasPermission, + getAdminWallets, + addAdminWallet, + deleteAdminWallet, + } = useAdmin(walletAddress, adminProvider); + // Local states - const [isCurrentUserAdmin, setIsCurrentUserAdmin] = useState(false); const [bountyFormLoading, setBountyFormLoading] = useState(false); const [bountyFormError, setBountyFormError] = useState(''); @@ -91,15 +131,9 @@ const Bounties = () => { const [submissionLoading, setSubmissionLoading] = useState(false); const [submissionError, setSubmissionError] = useState(''); - // Estados para Web3 y votación - const [walletConnected, setWalletConnected] = useState(false); - const [walletAddress, setWalletAddress] = useState(null); + // Estados para submissions const [submissions, setSubmissions] = useState([]); const [loadingSubmissions, setLoadingSubmissions] = useState(false); - const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); - const [isWalletHover, setIsWalletHover] = useState(false); - const [ensName, setEnsName] = useState(null); - const [web3Provider, setWeb3Provider] = useState(null); // Estado para mostrar/ocultar panel de creación const [showCreatePanel, setShowCreatePanel] = useState(false); @@ -112,22 +146,76 @@ const Bounties = () => { Icon: COLUMN_ICONS[key], })), [t]); - // Al montar, intenta restaurar la wallet desde localStorage y verificar whitelist + // Al montar, intenta restaurar la wallet usando EIP-6963 para encontrar el provider correcto useEffect(() => { const restoreWalletAndCheckWhitelist = async () => { const savedWallet = localStorage.getItem('walletAddress'); - if (savedWallet && window.ethereum) { - setWalletConnected(true); - setWalletAddress(savedWallet); + const savedWalletId = localStorage.getItem('walletProviderId'); + + if (!savedWallet) return; + + // Usar EIP-6963 para encontrar el provider correcto + const eip6963Providers = new Map(); + + const handleAnnounceProvider = (event) => { + const { info, provider } = event.detail; + eip6963Providers.set(info.uuid, { info, provider }); + }; - // Crear provider y verificar whitelist + window.addEventListener('eip6963:announceProvider', handleAnnounceProvider); + window.dispatchEvent(new Event('eip6963:requestProvider')); + + // Esperar un poco para que las wallets respondan + await new Promise(resolve => setTimeout(resolve, 300)); + window.removeEventListener('eip6963:announceProvider', handleAnnounceProvider); + + // Buscar el provider guardado o uno que tenga la cuenta + let selectedProvider = null; + + // Primero buscar por ID guardado + if (savedWalletId) { + for (const [uuid, { info, provider }] of eip6963Providers) { + if (uuid === savedWalletId || info.name.toLowerCase().includes(savedWalletId.toLowerCase())) { + selectedProvider = provider; + break; + } + } + } + + // Si no encontramos por ID, buscar el que tenga la cuenta conectada + if (!selectedProvider) { + for (const [, { provider }] of eip6963Providers) { + try { + const accounts = await provider.request({ method: 'eth_accounts' }); + if (accounts && accounts.some(acc => acc.toLowerCase() === savedWallet.toLowerCase())) { + selectedProvider = provider; + break; + } + } catch { + // Provider no tiene acceso, continuar + } + } + } + + if (selectedProvider) { try { - const provider = new ethers.providers.Web3Provider(window.ethereum, 'any'); - setWeb3Provider(provider); - await checkWhitelistStatus(savedWallet, provider); + const ethersProvider = new ethers.providers.Web3Provider(selectedProvider, 'any'); + setWalletConnected(true); + setWalletAddress(savedWallet); + setWeb3Provider(ethersProvider); + setAdminProvider(ethersProvider); + await checkWhitelistStatus(savedWallet, ethersProvider); } catch { - // Si falla, el usuario deberá reconectar manualmente + // Si falla, limpiar y el usuario deberá reconectar + localStorage.removeItem('walletAddress'); + localStorage.removeItem('walletProviderId'); } + } else { + // No encontramos el provider correcto, mostrar solo la dirección pero sin provider + // El usuario deberá reconectar para acciones que requieran firma + setWalletConnected(true); + setWalletAddress(savedWallet); + // No establecemos adminProvider, así el admin auth fallará y pedirá reconectar } }; @@ -153,23 +241,6 @@ const Bounties = () => { } }, [walletAddress]); - // Verificar si hay admin token guardado (solo para admins especiales) - useEffect(() => { - const adminToken = localStorage.getItem('adminToken'); - if (adminToken) { - // Verificar si el token es válido y es admin - legacyAPI.auth.verify(adminToken) - .then(data => { - if (data.user && data.user.role === 'admin') { - setIsCurrentUserAdmin(true); - } - }) - .catch(() => { - localStorage.removeItem('adminToken'); - }); - } - }, []); - const handleReturn = () => { navigate('/', { replace: true }); }; @@ -258,6 +329,7 @@ const Bounties = () => { setWalletConnected(true); setWalletAddress(address); setWeb3Provider(provider); + setAdminProvider(provider); // Para el hook de admin setIsWalletModalOpen(false); localStorage.setItem('walletAddress', address); @@ -269,8 +341,11 @@ const Bounties = () => { setWalletConnected(false); setWalletAddress(null); setWeb3Provider(null); + setAdminProvider(null); resetWhitelist(); + logoutAdmin(); // Cerrar sesión de admin localStorage.removeItem('walletAddress'); + localStorage.removeItem('walletProviderId'); }; // Utilidad para abreviar la dirección @@ -305,6 +380,187 @@ const Bounties = () => { } }, [selectedBounty]); + // ==================== FUNCIONES DE ADMIN ==================== + + // Estado para editar bounty + const [editingBounty, setEditingBounty] = useState(null); + const [editFormData, setEditFormData] = useState({}); + const [adminActionLoading, setAdminActionLoading] = useState(false); + const [showAdminWalletsModal, setShowAdminWalletsModal] = useState(false); + + // Estados para selector de token en edición + const [editSelectedToken, setEditSelectedToken] = useState(REWARD_TOKENS[0]); + const [editCustomToken, setEditCustomToken] = useState(''); + const [showEditTokenDropdown, setShowEditTokenDropdown] = useState(false); + const editDropdownRef = React.useRef(null); + + // Estado para modal de confirmación de eliminación + const [deleteConfirmModal, setDeleteConfirmModal] = useState({ show: false, bountyId: null, bountyTitle: '' }); + + // Cerrar dropdown de edición al hacer clic fuera + useEffect(() => { + const handleClickOutside = (event) => { + if (editDropdownRef.current && !editDropdownRef.current.contains(event.target)) { + setShowEditTokenDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Autenticar como admin + const handleAdminAuth = async () => { + if (!walletConnected) { + showToast.error(t('admin.connect_wallet_first') || 'Conecta tu wallet primero'); + return; + } + // Si no hay provider (wallet restaurada sin conexión activa), pedir reconectar + if (!adminProvider) { + showToast.error(t('admin.reconnect_wallet') || 'Por favor, reconecta tu wallet para firmar'); + setIsWalletModalOpen(true); + return; + } + const success = await authenticateAdmin(); + if (success) { + showToast.success(t('admin.auth_success') || 'Autenticado como admin'); + } else { + showToast.error(adminError || t('admin.auth_failed') || 'Error de autenticación'); + } + }; + + // Cambiar estado de bounty + const handleStatusChange = async (bountyId, newStatus, e) => { + if (e) e.stopPropagation(); + if (!isAdminAuthenticated) { + showToast.error(t('admin.auth_required') || 'Debes autenticarte como admin'); + return; + } + + setAdminActionLoading(true); + try { + await adminUpdateStatus(bountyId, newStatus); + showToast.success(t('admin.status_updated') || 'Estado actualizado'); + fetchBounties(); // Recargar bounties + if (selectedBounty?._id === bountyId) { + closeBountyDetails(); + } + } catch (err) { + showToast.error(err.message || t('admin.status_error') || 'Error al cambiar estado'); + } finally { + setAdminActionLoading(false); + } + }; + + // Mostrar modal de confirmación para eliminar bounty + const handleDeleteBounty = (bountyId, bountyTitle, e) => { + if (e) e.stopPropagation(); + if (!isAdminAuthenticated) { + showToast.error(t('admin.auth_required') || 'Debes autenticarte como admin'); + return; + } + setDeleteConfirmModal({ show: true, bountyId, bountyTitle }); + }; + + // Confirmar eliminación de bounty + const confirmDeleteBounty = async () => { + const { bountyId } = deleteConfirmModal; + setDeleteConfirmModal({ show: false, bountyId: null, bountyTitle: '' }); + + setAdminActionLoading(true); + try { + await adminDeleteBounty(bountyId); + showToast.success(t('admin.bounty_deleted') || 'Bounty eliminado'); + fetchBounties(); + if (selectedBounty?._id === bountyId) { + closeBountyDetails(); + } + } catch (err) { + showToast.error(err.message || t('admin.delete_error') || 'Error al eliminar'); + } finally { + setAdminActionLoading(false); + } + }; + + // Abrir modal de edición + const handleEditBounty = (bounty, e) => { + if (e) e.stopPropagation(); + if (!isAdminAuthenticated) { + showToast.error(t('admin.auth_required') || 'Debes autenticarte como admin'); + return; + } + + // Parsear el reward existente (ej: "34 USDC" → amount: 34, token: USDC) + let rewardAmount = ''; + let tokenSymbol = 'USDC'; + if (bounty.reward) { + const parts = bounty.reward.trim().split(' '); + if (parts.length >= 2) { + rewardAmount = parts[0]; + tokenSymbol = parts.slice(1).join(' ').toUpperCase(); + } else { + rewardAmount = bounty.reward; + } + } + + // Buscar el token en la lista o marcarlo como custom + const foundToken = REWARD_TOKENS.find(t => t.symbol === tokenSymbol); + if (foundToken) { + setEditSelectedToken(foundToken); + setEditCustomToken(''); + } else { + setEditSelectedToken(REWARD_TOKENS.find(t => t.symbol === 'CUSTOM')); + setEditCustomToken(tokenSymbol); + } + + setEditingBounty(bounty); + setEditFormData({ + title: bounty.title, + description: bounty.description, + rewardAmount: rewardAmount, + endDate: bounty.endDate ? new Date(bounty.endDate).toISOString().split('T')[0] : '', + }); + }; + + // Construir reward string para edición + const getEditRewardString = () => { + const amount = (editFormData.rewardAmount || '').toString().trim(); + if (!amount) return ''; + const tokenSymbol = editSelectedToken.symbol === 'CUSTOM' ? editCustomToken.trim() : editSelectedToken.symbol; + return tokenSymbol ? `${amount} ${tokenSymbol}` : amount; + }; + + // Guardar edición + const handleSaveEdit = async () => { + if (!editingBounty) return; + + setAdminActionLoading(true); + try { + const dataToSave = { + title: editFormData.title, + description: editFormData.description, + reward: getEditRewardString(), + endDate: editFormData.endDate || null, + }; + await adminUpdateBounty(editingBounty._id, dataToSave); + showToast.success(t('admin.bounty_updated') || 'Bounty actualizado'); + setEditingBounty(null); + fetchBounties(); + } catch (err) { + showToast.error(err.message || t('admin.update_error') || 'Error al actualizar'); + } finally { + setAdminActionLoading(false); + } + }; + + // Seleccionar token en edición + const handleEditTokenSelect = (token) => { + setEditSelectedToken(token); + setShowEditTokenDropdown(false); + if (token.symbol !== 'CUSTOM') { + setEditCustomToken(''); + } + }; + return ( { {t('success.back_home')} - {/* Botón de Wallet */} + {/* Botón de Wallet y Admin */}
+ {/* Botón de Admin (solo si es admin) */} + {walletConnected && isAdmin && ( +
+ {/* Botón de gestionar wallets (solo si autenticado y superadmin) */} + {isAdminAuthenticated && hasPermission('manage_admins') && ( + setShowAdminWalletsModal(true)} + className="p-2 rounded-lg bg-amber-900/30 text-amber-400 border border-amber-500/30 hover:bg-amber-900/50 transition-all duration-200" + title={t('admin.manage_wallets') || 'Manage Admin Wallets'} + > + + + )} + + + {adminAuthLoading + ? '...' + : isAdminAuthenticated + ? t('admin.logout') || 'Admin ✓' + : t('admin.login') || 'Admin'} + +
+ )} {!walletConnected ? ( {
{/* Botón para mostrar/ocultar panel de creación */} - {!isCurrentUserAdmin && ( + {( { )} - {/* Panel de admin */} - {isCurrentUserAdmin && ( - -

{t('adminPanel.title')}

-

{t('adminPanel.description')}

- -
- )} - {/* Columnas de Bounties */} { )}
- {/* Link a Snapshot cuando está en votación */} - {task.status === 'voting' && ( + {/* Link a Snapshot - diferente según el estado real */} + {task.status === 'voting' && !isVotingExpired(task) && ( { {t('bountyDetails.vote_on_snapshot') || 'Votar en Snapshot'} )} + + {/* Controles de Admin */} + {isAdmin && isAdminAuthenticated && ( +
+
+ {/* Selector de estado */} + + {/* Botón editar */} + + {/* Botón eliminar */} + +
+
+ )} )) )} @@ -759,8 +1071,18 @@ const Bounties = () => { )} {selectedBounty.status && ( - - {t(`bountyDetails.status_values.${selectedBounty.status}`)} + + {selectedBounty.status === 'voting' && isVotingExpired(selectedBounty) + ? t('bountyDetails.status_values.done') + : t(`bountyDetails.status_values.${selectedBounty.status}`)} )}
@@ -768,12 +1090,17 @@ const Bounties = () => { {/* Contenido scrolleable */}
-

{selectedBounty.description}

+ + {selectedBounty.description} + - {/* Mensaje cuando está en votación - submissions cerradas */} - {selectedBounty.status === 'voting' && ( + {/* Mensaje cuando está en votación activa - submissions cerradas */} + {selectedBounty.status === 'voting' && !isVotingExpired(selectedBounty) && (
@@ -804,8 +1131,8 @@ const Bounties = () => {
)} - {/* Mensaje cuando la bounty está terminada */} - {(selectedBounty.status === 'finished' || selectedBounty.status === 'done') && ( + {/* Mensaje cuando la bounty está terminada (status done O votación expirada) */} + {(selectedBounty.status === 'finished' || selectedBounty.status === 'done' || (selectedBounty.status === 'voting' && isVotingExpired(selectedBounty))) && (
@@ -909,6 +1236,228 @@ const Bounties = () => {
+ {/* Modal de Edición de Bounty (Admin) */} + {editingBounty && ( + setEditingBounty(null)} + > + e.stopPropagation()} + > +
+
+

+ + {t('admin.edit_bounty') || 'Editar Bounty'} +

+ +
+
+ +
+
+ + setEditFormData({ ...editFormData, title: e.target.value })} + className="w-full px-4 py-2 rounded-lg border border-ultraviolet/20 bg-background focus:border-ultraviolet focus:ring-2 focus:ring-ultraviolet outline-none text-text-primary" + /> +
+ +
+ +