diff --git a/packages/admin-ui/src/components/modals/BackupNodeModal.tsx b/packages/admin-ui/src/components/modals/BackupNodeModal.tsx new file mode 100644 index 0000000000..b3caa0c98b --- /dev/null +++ b/packages/admin-ui/src/components/modals/BackupNodeModal.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import dappnodeServerShield from "img/dappnode_server_shield.png"; +import BasePromotionModal from "./BasePromotionModal"; +import { useNavigate } from "react-router-dom"; +import { relativePath as premiumRelativePath } from "pages/premium/data"; +import { premiumLanding } from "params"; +import { Network } from "@dappnode/types"; +import { usePremium } from "hooks/premium/usePremium"; + +interface BackupNodeModalProps { + show: boolean; + onClose: (shouldContinue: boolean) => void; +} + +/** + * BackupNodeModal component + * + * This modal is used to prompt users to upgrade to Premium when changing execution clients. + * + * Primary Button ("Upgrade to Premium") + * - Navigates to the premium tab + * - Calls onClose(false) to abort the flow + * + * Secondary Button ("Learn More") + * - Opens the external documentation link in a new tab + * - Does NOT close the modal + * - User can read the landing page and come back to the modal + * + * Close Button (X) or Backdrop Click + * - Calls onClose(true) to continue with the flow + * + */ + +export default function BackupNodeModal({ show, onClose }: BackupNodeModalProps) { + const navigate = useNavigate(); + + const navigateToPremiumTab = () => { + navigate(`/${premiumRelativePath}`); + onClose(false); // Abort the flow + }; + + return ( + onClose(true)} // Continue with the flow when closing via X or backdrop + title="Node runner, you are about to lose rewards!" + description="The Backup node keeps your validators attesting and proposing blocks when your local clients are syncing or not available. No more downtime." + imageSrc={dappnodeServerShield} + imageAlt="DAppNode Server Shield" + primaryButtonText="Upgrade to Premium" + primaryButtonAction={navigateToPremiumTab} + secondaryButton={{ + type: "external-link", + text: "Learn More", + href: premiumLanding + }} + /> + ); +} + +/** + * Custom hook to handle the backup node modal in the stakers tab + * + * @param network - The current network + * @param isExecutionChanged - Whether the execution client has changed + * @param isSignerSelected - Whether the signer is selected + * @returns An object containing the modal component props and a function to show the modal + */ +export function useBackupNodeModal(network: Network, isExecutionChanged: boolean, isSignerSelected: boolean) { + const { isActivated: isPremium } = usePremium(); + const [showBackupModal, setShowBackupModal] = React.useState(false); + const modalResolveRef = React.useRef<((value: boolean) => void) | null>(null); + + const handleBackupModalClose = (shouldContinue: boolean) => { + setShowBackupModal(false); + if (modalResolveRef.current) { + modalResolveRef.current(shouldContinue); + modalResolveRef.current = null; + } + }; + + /** + * Shows the backup node modal if it meets its conditions and waits for user decision + * @returns Promise that resolves to true if user wants to continue, false if they abort + */ + async function showBackupNodeModal(): Promise { + if (isExecutionChanged && isSignerSelected) { + if (!isPremium) { + // Only show backup modal for certain networks + if (network === Network.Mainnet || network === Network.Gnosis || network === Network.Hoodi) { + const shouldContinue = await new Promise((resolve) => { + modalResolveRef.current = resolve; + setShowBackupModal(true); + }); + return shouldContinue; + } + } + } + + return true; + } + + return { + show: showBackupModal, + onClose: handleBackupModalClose, + showBackupNodeModal + }; +} diff --git a/packages/admin-ui/src/components/modals/BasePromotionModal.tsx b/packages/admin-ui/src/components/modals/BasePromotionModal.tsx new file mode 100644 index 0000000000..0aca3d8b83 --- /dev/null +++ b/packages/admin-ui/src/components/modals/BasePromotionModal.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { externalUrlProps } from "params"; +import { Link } from "react-router-dom"; + +import "./basePromotionModal.scss"; + +interface BasePromotionModalProps { + show: boolean; + onClose: () => void; + title: string; + description: string; + imageSrc: string; + imageAlt: string; + primaryButtonText: string; + primaryButtonAction: () => void; + secondaryButton?: { + text: string; + } & ( + | { type: "action"; action: () => void } + | { type: "internal-link"; to: string } + | { type: "external-link"; href: string; onClick?: () => void } + ); +} + +export default function BasePromotionModal({ + show, + onClose, + title, + description, + imageSrc, + imageAlt, + primaryButtonText, + primaryButtonAction, + secondaryButton +}: BasePromotionModalProps) { + if (!show) return null; + + const renderSecondaryButton = () => { + if (!secondaryButton) return null; + + const buttonElement = ( + + ); + + switch (secondaryButton.type) { + case "action": + return ( + + ); + case "internal-link": + return ( + + {buttonElement} + + ); + case "external-link": + return ( + + {buttonElement} + + ); + default: + return null; + } + }; + + return ( +
+
e.stopPropagation()}> + + +
+

{title}

+
+ +
+ {imageAlt} +
+ +
+
+

{description}

+
+ +
+ + {renderSecondaryButton()} +
+
+
+
+ ); +} diff --git a/packages/admin-ui/src/components/modals/basePromotionModal.scss b/packages/admin-ui/src/components/modals/basePromotionModal.scss new file mode 100644 index 0000000000..07da6624f0 --- /dev/null +++ b/packages/admin-ui/src/components/modals/basePromotionModal.scss @@ -0,0 +1,209 @@ +// Modal overlay - full screen with semi-transparent background +.promotion-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +// Modal container with gradient background +.promotion-modal-container { + position: relative; + background: linear-gradient(135deg, #7b2ff2 0%, #f357a8 50%, #7b2ff2 100%); + color: #fff; + padding: 2.5rem; + border-radius: 1rem; + max-width: 600px; + width: 100%; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + animation: modalFadeIn 0.3s ease-out; +} + +// Dark mode text color override +#dark { + #main { + .promotion-modal-container { + h2, + p { + color: #fff; + } + } + } +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +// Close button +.promotion-modal-close { + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(255, 255, 255, 0.2); + border: none; + color: #fff; + font-size: 2rem; + line-height: 1; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + + &:focus { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 2px; + } +} + +// Modal header +.promotion-modal-header { + margin-bottom: 1.5rem; +} + +.promotion-modal-title { + font-size: 2rem; + font-weight: bold; + text-align: center; + margin: 0; + padding: 0; +} + +// Modal image +.promotion-modal-image-container { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 1.5rem; +} + +.promotion-modal-image { + max-width: 200px; + width: 100%; + height: auto; + animation: floatAnimation 3s ease-in-out infinite; +} + +@keyframes floatAnimation { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +// Modal body +.promotion-modal-body { + display: flex; + flex-direction: column; +} + +.promotion-modal-description { + font-size: 1.2rem; + margin-bottom: 2rem; + text-align: center; + line-height: 1.6; + + p { + margin: 0; + } +} + +// Button container +.promotion-modal-button-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +// Buttons +.promotion-full-width-button { + width: 100%; + font-size: 1.1rem; + font-weight: 600; + border-radius: 0.5rem; + padding: 0.875rem 1.5rem; + border: none; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-block; + text-align: center; +} + +.promotion-button-primary { + background: #fff; + color: #7b2ff2; + + &:hover { + background: rgba(255, 255, 255, 0.9); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + + &:active { + transform: translateY(0); + } +} + +.promotion-button-secondary { + background: rgba(255, 255, 255, 0.2); + color: #fff; + border: 2px solid rgba(255, 255, 255, 0.4); + + &:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.6); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + + &:active { + transform: translateY(0); + } +} + +// Link button wrapper +.promotion-link-button { + text-decoration: none; + display: block; +} + +// Responsive design +@media (max-width: 768px) { + .promotion-modal-container { + padding: 2rem; + margin: 1rem; + } + + .promotion-modal-title { + font-size: 1.5rem; + } + + .promotion-modal-description { + font-size: 1rem; + } +} diff --git a/packages/admin-ui/src/img/dappnode_server_shield.png b/packages/admin-ui/src/img/dappnode_server_shield.png new file mode 100644 index 0000000000..950ea45473 Binary files /dev/null and b/packages/admin-ui/src/img/dappnode_server_shield.png differ diff --git a/packages/admin-ui/src/pages/stakers/components/StakerNetwork.tsx b/packages/admin-ui/src/pages/stakers/components/StakerNetwork.tsx index f527464334..63a798999c 100644 --- a/packages/admin-ui/src/pages/stakers/components/StakerNetwork.tsx +++ b/packages/admin-ui/src/pages/stakers/components/StakerNetwork.tsx @@ -22,6 +22,7 @@ import { docsSmooth } from "params"; import { BsInfoCircleFill } from "react-icons/bs"; import { CustomAccordion, CustomAccordionItem } from "components/CustomAccordion"; import { Link } from "react-router-dom"; +import BackupNodeModal, { useBackupNodeModal } from "components/modals/BackupNodeModal"; import "./stakers.scss"; @@ -48,27 +49,30 @@ export default function StakerNetwork({ network, description }: { network: Netwo changes } = useStakerConfig(network, currentStakerConfigReq); + const isExecutionChanged = + newExecClient?.dnpName !== + currentStakerConfigReq.data?.executionClients.find((ec) => ec.status === "ok" && ec.isSelected)?.dnpName; + + const isSignerSelected = Boolean(newWeb3signer?.isSelected); + + // Backup node modal hook + const { show, onClose, showBackupNodeModal } = useBackupNodeModal(network, isExecutionChanged, isSignerSelected); + /** * Set new staker config */ async function setNewConfig(isLaunchpad: boolean) { + let showToast = false; try { // Make sure there are changes if (changes) { // TODO: Ask for removing the previous Execution Client and/or Consensus Client if its different + const backupModalContinue = await showBackupNodeModal(); + if (!backupModalContinue) { + return; + } + if (!isLaunchpad) { - await new Promise((resolve: (confirmOnSetConfig: boolean) => void) => { - confirm({ - title: `Staker configuration`, - text: "Are you sure you want to implement this staker configuration?", - buttons: [ - { - label: "Continue", - onClick: () => resolve(true) - } - ] - }); - }); await new Promise((resolve: (confirmOnSetConfig: boolean) => void) => { confirm({ title: `Disclaimer`, @@ -76,6 +80,7 @@ export default function StakerNetwork({ network, description }: { network: Netwo buttons: [ { label: "Continue", + variant: "dappnode", onClick: () => resolve(true) } ] @@ -104,22 +109,28 @@ export default function StakerNetwork({ network, description }: { network: Netwo } ); setReqStatus({ result: true }); + showToast = true; } } catch (e) { setReqStatus({ error: e }); + showToast = true; } finally { - setReqStatus({ loading: true }); - await withToast(() => currentStakerConfigReq.revalidate(), { - message: `Getting new ${network} staker configuration`, - onSuccess: `Successfully loaded ${network} staker configuration`, - onError: `Error new loading ${network} staker configuration` - }); - setReqStatus({ loading: false }); + if (showToast) { + setReqStatus({ loading: true }); + await withToast(() => currentStakerConfigReq.revalidate(), { + message: `Getting new ${network} staker configuration`, + onSuccess: `Successfully loaded ${network} staker configuration`, + onError: `Error new loading ${network} staker configuration` + }); + setReqStatus({ loading: false }); + } } } return (
+ + {network === Network.Prater && (

@@ -204,7 +215,7 @@ export default function StakerNetwork({ network, description }: { network: Netwo /> ))} - + {/* Only shows consensus clients if data is available */} {currentStakerConfigReq.data.consensusClients.length > 0 && (