diff --git a/src/components/common/InfoTooltipButton/cmp.tsx b/src/components/common/InfoTooltipButton/cmp.tsx index 7edc95aa..57dbd123 100644 --- a/src/components/common/InfoTooltipButton/cmp.tsx +++ b/src/components/common/InfoTooltipButton/cmp.tsx @@ -1,9 +1,10 @@ import { memo, useEffect, useRef, useState } from 'react' import { StyledInfoTooltipButton } from './styles' import { InfoTooltipButtonProps } from './types' -import { Icon, Tooltip, useResponsiveMax } from '@aleph-front/core' +import { Icon } from '@aleph-front/core' import tw from 'twin.macro' import { TwStyle } from 'twin.macro' +import ResponsiveTooltip from '../ResponsiveTooltip' export const InfoTooltipButton = ({ children, @@ -23,12 +24,6 @@ export const InfoTooltipButton = ({ const targetRef = useRef(null) - // @todo: Improve this using css - const isMobile = useResponsiveMax('md') - const mobileTw = isMobile - ? tw`!fixed !left-0 !top-0 !transform-none m-6 !z-20 !w-[calc(100% - 3rem)] !h-[calc(100% - 3rem)] !max-w-full` - : tw`` - const iconCss: (TwStyle | string)[] = [] switch (align) { case 'left': @@ -63,11 +58,10 @@ export const InfoTooltipButton = ({ )} {renderTooltip && ( - )} diff --git a/src/components/common/ResponsiveTooltip/cmp.tsx b/src/components/common/ResponsiveTooltip/cmp.tsx new file mode 100644 index 00000000..aed38786 --- /dev/null +++ b/src/components/common/ResponsiveTooltip/cmp.tsx @@ -0,0 +1,15 @@ +import { memo } from 'react' +import { Tooltip, TooltipProps, useResponsiveMax } from '@aleph-front/core' +import tw from 'twin.macro' + +export const ResponsiveTooltip = (props: TooltipProps) => { + const isMobile = useResponsiveMax('md') + const css = isMobile + ? tw`!fixed !left-0 !top-0 !transform-none m-6 !z-20 !w-[calc(100% - 3rem)] !h-[calc(100% - 3rem)] !max-w-full` + : '' + + return +} +ResponsiveTooltip.displayName = 'ResponsiveTooltip' + +export default memo(ResponsiveTooltip) as typeof ResponsiveTooltip diff --git a/src/components/common/ResponsiveTooltip/index.ts b/src/components/common/ResponsiveTooltip/index.ts new file mode 100644 index 00000000..7a9f83f3 --- /dev/null +++ b/src/components/common/ResponsiveTooltip/index.ts @@ -0,0 +1 @@ +export { default } from './cmp' diff --git a/src/components/common/ResponsiveTooltip/styles.ts b/src/components/common/ResponsiveTooltip/styles.ts new file mode 100644 index 00000000..07ff26f9 --- /dev/null +++ b/src/components/common/ResponsiveTooltip/styles.ts @@ -0,0 +1,20 @@ +import { Tooltip } from '@aleph-front/core' +import styled from 'styled-components' +import tw from 'twin.macro' + +export type StyledResponsiveTooltipProps = { + $isMobile: boolean +} + +export const StyledResponsiveTooltip = styled( + Tooltip, +).attrs(({ $isMobile, ...tooltipProps }) => { + const css = $isMobile + ? tw`!absolute !top-0 !left-0 !transform-none !z-50 !w-[calc(100% - 3rem)] !h-[calc(100% - 3rem)] !max-w-full !m-48` + : '' + + return { + ...tooltipProps, + css, + } +})`` diff --git a/src/components/form/CheckoutSummary/cmp.tsx b/src/components/form/CheckoutSummary/cmp.tsx index eafa6ece..26fde55f 100644 --- a/src/components/form/CheckoutSummary/cmp.tsx +++ b/src/components/form/CheckoutSummary/cmp.tsx @@ -250,6 +250,7 @@ export const CheckoutSummary = ({ domains, description, button: buttonNode, + footerButton = buttonNode, control, receiverAddress, paymentMethod, @@ -257,6 +258,7 @@ export const CheckoutSummary = ({ mainRef, isPersistent = type === EntityType.Instance, disablePaymentMethod = true, + disabledStreamTooltip, onSwitchPaymentMethod, }: // streamDuration, CheckoutSummaryProps) => { @@ -289,6 +291,7 @@ CheckoutSummaryProps) => { disabledHold={disabledHold} disabledStream={disabledStream} onSwitch={onSwitchPaymentMethod} + disabledStreamTooltip={disabledStreamTooltip} /> ) @@ -298,7 +301,7 @@ CheckoutSummaryProps) => { paymentMethod: PaymentMethod @@ -32,6 +33,7 @@ export type CheckoutSummaryProps = { receiverAddress?: string streamDuration?: StreamDurationField disablePaymentMethod?: boolean + disabledStreamTooltip?: ReactNode onSwitchPaymentMethod?: (e: PaymentMethod) => void } diff --git a/src/components/form/SelectPaymentMethod/cmp.tsx b/src/components/form/SelectPaymentMethod/cmp.tsx index 95b306e1..9053b8ec 100644 --- a/src/components/form/SelectPaymentMethod/cmp.tsx +++ b/src/components/form/SelectPaymentMethod/cmp.tsx @@ -3,42 +3,62 @@ import { Switch } from '@aleph-front/core' import { SelectPaymentMethodProps } from './types' import { useSelectPaymentMethod } from '@/hooks/form/useSelectPaymentMethod' import { StyledLabel } from './styles' +import { PaymentMethod } from '@/helpers/constants' +import ResponsiveTooltip from '@/components/common/ResponsiveTooltip' export const SelectPaymentMethod = (props: SelectPaymentMethodProps) => { const { disabledHold, disabledStream, paymentMethodCtrl, + disabledStreamTooltip, + switchRef, handleClickHold, handleClickStream, } = useSelectPaymentMethod(props) return ( -
- +
- Hold tokens - - - - Pay-as-you-go - -
+ + Hold tokens + + + + Pay-as-you-go + +
+ {disabledStream && ( + } + content={disabledStreamTooltip} + /> + )} + ) } diff --git a/src/components/form/SelectPaymentMethod/styles.tsx b/src/components/form/SelectPaymentMethod/styles.tsx index c322191a..177bfecb 100644 --- a/src/components/form/SelectPaymentMethod/styles.tsx +++ b/src/components/form/SelectPaymentMethod/styles.tsx @@ -1,14 +1,11 @@ import tw from 'twin.macro' import styled, { css } from 'styled-components' -export type StyledLabelProps = { $disabled?: boolean } +export type StyledLabelProps = { $selected?: boolean; $disabled?: boolean } export const StyledLabel = styled.label` - ${({ $disabled = false }) => css` - /* ${tw`cursor-pointer`} */ - /* @todo: fix it after supporting stream payment with automatic allocation or hold payment with manual allocation */ - ${tw`cursor-not-allowed`} - + ${({ $selected = false, $disabled = false }) => css` + ${$disabled ? tw`cursor-not-allowed` : !$selected ? tw`cursor-pointer` : ''} ${$disabled && css` ${tw`opacity-40 cursor-not-allowed`} diff --git a/src/components/pages/computing/NewInstancePage/cmp.tsx b/src/components/pages/computing/NewInstancePage/cmp.tsx index d4e58427..01d6da39 100644 --- a/src/components/pages/computing/NewInstancePage/cmp.tsx +++ b/src/components/pages/computing/NewInstancePage/cmp.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import React from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import Image from 'next/image' import { Button, @@ -8,10 +9,9 @@ import { NodeScore, TableColumn, NoisyContainer, - useModal, + TooltipProps, } from '@aleph-front/core' import { CRN } from '@/domain/node' -import { BlockchainId, blockchains } from '@/domain/connect/base' import SelectInstanceImage from '@/components/form/SelectInstanceImage' import SelectInstanceSpecs from '@/components/form/SelectInstanceSpecs' import AddVolumes from '@/components/form/AddVolumes' @@ -27,7 +27,10 @@ import { apiServer, } from '@/helpers/constants' import Container from '@/components/common/CenteredContainer' -import { useNewInstancePage } from '@/hooks/pages/computing/useNewInstancePage' +import { + useNewInstancePage, + UseNewInstancePageReturn, +} from '@/hooks/pages/computing/useNewInstancePage' import Form from '@/components/form/Form' import ToggleContainer from '@/components/common/ToggleContainer' import NewEntityTab from '../NewEntityTab' @@ -36,414 +39,135 @@ import SpinnerOverlay from '@/components/common/SpinnerOverlay' import { SectionTitle } from '@/components/common/CompositeTitle' import { PageProps } from '@/types/types' import Strong from '@/components/common/Strong' -import { useConnection } from '@/hooks/common/useConnection' import CRNList from '../../../common/CRNList' import BackButtonSection from '@/components/common/BackButtonSection' -import { isBlockchainSupported as isBlockchainPAYGCompatible } from '@aleph-sdk/superfluid' -import { isBlockchainHoldingCompatible } from '@/domain/blockchain' +import ResponsiveTooltip from '@/components/common/ResponsiveTooltip' +import BorderBox from '@/components/common/BorderBox' + +const CheckoutButton = React.memo( + ({ + disabled, + handleSubmit, + title = 'Create instance', + tooltipContent, + isFooter, + }: { + disabled: boolean + handleSubmit: UseNewInstancePageReturn['handleSubmit'] + title?: string + tooltipContent?: TooltipProps['content'] + isFooter: boolean + }) => { + const checkoutButtonRef = useRef(null) + + return ( + <> + + {tooltipContent && ( + + )} + + ) + }, +) +CheckoutButton.displayName = 'CheckoutButton' export default function NewInstancePage({ mainRef }: PageProps) { const { address, accountBalance, - isCreateButtonDisabled, + blockchainName, + streamDisabled, + disabledStreamDisabledMessage, + manuallySelectCRNDisabled, + manuallySelectCRNDisabledMessage, + createInstanceDisabled, + createInstanceDisabledMessage, + createInstanceButtonTitle, values, control, errors, node, nodeSpecs, lastVersion, - handleSubmit, + selectedModal, + setSelectedModal, + selectedNode, + setSelectedNode, + modalOpen, + modalClose, + handleManuallySelectCRN, handleSelectNode, + handleSubmit, + handleCloseModal, handleBack, } = useNewInstancePage() const sectionNumber = useCallback((n: number) => (node ? 1 : 0) + n, [node]) - const { account, blockchain, handleConnect } = useConnection({ - triggerOnMount: false, - }) // ------------------ - const modal = useModal() - const modalOpen = modal?.open - const modalClose = modal?.close - - const [selectedNode, setSelectedNode] = useState() - const [selectedModal, setSelectedModal] = useState< - | 'node-list' - | 'switch-to-hold' - | 'switch-to-stream' - | 'switch-to-auto-hold' - | 'switch-to-node-stream' - >() - - const handleCloseModal = useCallback(() => setSelectedModal(undefined), []) - - const handleSwitchToNodeStream = useCallback(() => { - if (!isBlockchainPAYGCompatible(blockchain)) - handleConnect({ blockchain: BlockchainId.BASE }) - - if (selectedNode !== node?.hash) handleSelectNode(selectedNode) - - setSelectedModal(undefined) - }, [blockchain, handleConnect, handleSelectNode, node, selectedNode]) - - const handleSwitchToAutoHold = useCallback(() => { - if (node?.hash) { - setSelectedNode(undefined) - handleSelectNode(undefined) - } - - if (!isBlockchainHoldingCompatible(blockchain)) - handleConnect({ blockchain: BlockchainId.ETH }) - - setSelectedModal(undefined) - }, [blockchain, handleConnect, handleSelectNode, node?.hash]) - + // Handle modals useEffect(() => { if (!modalOpen) return if (!modalClose) return - if (!selectedModal) { - return modalClose() - } - - if (selectedModal === 'node-list') { - return modalOpen({ - header: '', - width: '80rem', - onClose: handleCloseModal, - content: ( - - ), - footer: ( - <> -
- {node && ( + switch (selectedModal) { + case 'node-list': + return modalOpen({ + header: '', + width: '80rem', + onClose: handleCloseModal, + content: ( + + ), + footer: ( + <> +
- )} - -
- - ), - }) - } - - if (selectedModal === 'switch-to-hold') { - return modalOpen({ - width: '40rem', - title: 'Confirm Payment Method Change', - onClose: handleCloseModal, - content: ( -
-
- Switching to the Holder tier will set your - payment method to holding $ALEPH tokens on{' '} - Ethereum and{' '} - automatically select the most suitable CRN. -
-
- Switching modes will prompt your wallet to automatically adjust to - the Ethereum network if needed, and the system will reset to align - with your new selection preferences. Ensure your wallet is ready - for the Ethereum network. -
-
- Token Requirements -
- You will need to hold sufficient $ALEPH tokens in your wallet to - continue using the instance. $ALEPH can be acquired from Uniswap - or Coinbase. -
-
-
- ), - footer: ( -
- - -
- ), - }) - } - - if (selectedModal === 'switch-to-stream') { - return modalOpen({ - width: '40rem', - title: 'Confirm Payment Method Change', - onClose: handleCloseModal, - content: ( -
-
- You are about to switch your payment method to{' '} - Pay-as-you-go, which will also allow you to{' '} - manually select your preferred CRN on{' '} - Base. -
-
- Making this change will prompt your wallet to automatically switch - networks. This will reset your current configuration to - accommodate your new selection. Please ensure your wallet is - compatible and ready for this transition. -
-
- Token Requirements -
- You will need $ETH on Base to start the PAYG stream and $ALEPH - on Base to stream. Purchase $ETH on Base at Uniswap and get - $ALEPH on Base by swapping your Ethereum $ALEPH on - swap.aleph.im.
-
-
- ), - footer: ( -
- - -
- ), - }) - } - - if (selectedModal === 'switch-to-auto-hold') { - return modalOpen({ - width: '40rem', - title: 'Confirm CRN selection and Payment Method Change', - onClose: handleCloseModal, - content: ( -
-
- You are about to enable automatic CRN selection, - which will utilize the Holder-tier on{' '} - Ethereum. This simplifies your setup by - automatically assigning a CRN node. -
-
- Switching modes will prompt your wallet to automatically adjust to - the Ethereum network if needed, and the system will reset to align - with your new selection preferences. Ensure your wallet is ready - for the Ethereum network. -
-
- Token Requirements -
- You will need to hold sufficient $ALEPH tokens in your wallet to - continue using the instance. $ALEPH can be acquired from Uniswap - or Coinbase. -
-
-
- ), - footer: ( -
- - -
- ), - }) - } - - if (selectedModal === 'switch-to-node-stream') { - return modalOpen({ - width: '40rem', - title: 'Confirm CRN selection and Payment Method Change', - onClose: handleCloseModal, - content: ( -
-
- You are about to switch from the automated Holder-tier setup on - Ethereum to manually selecting a CRN with the{' '} - Pay-as-you-go method on Base. -
-
- Making this change will prompt your wallet to automatically switch - networks. This will reset your current configuration to - accommodate your new selection. Please ensure your wallet is - compatible and ready for this transition. -
-
- Token Requirements -
- You will need $ETH on Base to start the PAYG stream and $ALEPH - on Base to stream. Purchase $ETH on Base at Uniswap and get - $ALEPH on Base by swapping your Ethereum $ALEPH on - swap.aleph.im. -
-
-
- ), - footer: ( -
- - -
- ), - }) + + ), + }) + default: + return modalClose() } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - handleCloseModal, - handleConnect, - handleSelectNode, - handleSwitchToAutoHold, - handleSwitchToNodeStream, - // modalClose, - // modalOpen, - node, selectedModal, selectedNode, - ]) - // ------------------------ - - const handleSwitchPaymentMethod = useCallback((method: PaymentMethod) => { - if (method === PaymentMethod.Stream) { - setSelectedModal('switch-to-stream') - } else { - setSelectedModal('switch-to-hold') - } - }, []) - - // @note: warn the user when the wrong network configuration has been detected - useEffect(() => { - if (!modalOpen) return - if (!modalClose) return - - if (!account) return - if (!blockchain) return - if (selectedModal) return - - if ( - (node && isBlockchainPAYGCompatible(blockchain)) || - (!node && isBlockchainHoldingCompatible(blockchain)) - ) { - return modalClose() - } - - const switchTo = node ? BlockchainId.BASE : BlockchainId.ETH - const name = blockchains[switchTo].name - - return modalOpen({ - width: '40rem', - title: 'Network Switch Required', - onClose: modalClose, - content: ( -
-
- It looks like your wallet is currently connected to the wrong - network. To proceed with setting up your instance on - Twentysix.cloud, you'll need to switch to the{' '} - {name} network. -
-
- ), - footer: ( -
- - -
- ), - }) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - // modalClose, - // modalOpen, - handleConnect, - account, - blockchain, - node, - selectedModal, + setSelectedNode, + modalOpen, + modalClose, + handleSelectNode, + handleCloseModal, ]) // ------------------------ @@ -492,9 +216,11 @@ export default function NewInstancePage({ mainRef }: PageProps) { ), }, ] as TableColumn[] - }, [lastVersion]) + }, [lastVersion, setSelectedModal]) - const data = useMemo(() => (node ? [node] : []), [node]) + const nodeData = useMemo(() => (node ? [node] : []), [node]) + const manuallySelectButtonRef = useRef(null) + const manuallySelectButtonRef2 = useRef(null) return ( <> @@ -505,32 +231,62 @@ export default function NewInstancePage({ mainRef }: PageProps) { - {node && ( + {createInstanceDisabledMessage && ( +
+ + + {createInstanceDisabledMessage} + + +
+ )} + {values.paymentMethod === PaymentMethod.Stream && (
Selected instance +

+ Your instance is set up with your manually selected Compute + Resource Node (CRN), operating under the{' '} + Pay-as-you-go payment method on{' '} + {blockchainName}. This setup gives you direct + control over your resource allocation and costs, requiring + active management of your instance. To adjust your CRN or + explore different payment options, you can modify your selection + below. +

- ({ className: '_active' })} />
- + {!node && ( + <> + + {manuallySelectCRNDisabledMessage && ( + + )} + + )}
@@ -542,15 +298,24 @@ export default function NewInstancePage({ mainRef }: PageProps) { Select your tier -

- Your instance is ready to be configured using our{' '} - automated CRN selection, set to run on{' '} - Ethereum with the{' '} - Holder-tier payment method, allowing you seamless - access while you hold ALEPH tokens. If you wish to customize your - Compute Resource Node (CRN) or use a different payment approach, - you can change your selection below. -

+ {values.paymentMethod === PaymentMethod.Hold ? ( +

+ Your instance is ready to be configured using our{' '} + automated CRN selection, set to run on{' '} + {blockchainName} with the{' '} + Holder-tier payment method, allowing you + seamless access while you hold ALEPH tokens. If you wish to + customize your Compute Resource Node (CRN) or use a different + payment approach, you can change your selection below. +

+ ) : ( +

+ Please select one of the available instance tiers as a base for + your VM. You will be able to customize the volumes further below + in the form. +

+ )} +
+ {manuallySelectCRNDisabledMessage && ( + + )}
)} @@ -696,9 +471,9 @@ export default function NewInstancePage({ mainRef }: PageProps) { unlockedAmount={accountBalance} paymentMethod={values.paymentMethod} streamDuration={values.streamDuration} - disablePaymentMethod={false} + disablePaymentMethod={streamDisabled} + disabledStreamTooltip={disabledStreamDisabledMessage} mainRef={mainRef} - onSwitchPaymentMethod={handleSwitchPaymentMethod} description={ <> You can either leverage the traditional method of holding tokens @@ -708,19 +483,24 @@ export default function NewInstancePage({ mainRef }: PageProps) { feature, enabling real-time payment for resources as you use them. } + // Duplicate buttons to have different references for the tooltip on each one button={ - + + } + footerButton={ + } /> diff --git a/src/components/pages/computing/NewInstancePage/disabledMessages.tsx b/src/components/pages/computing/NewInstancePage/disabledMessages.tsx new file mode 100644 index 00000000..7d806fb3 --- /dev/null +++ b/src/components/pages/computing/NewInstancePage/disabledMessages.tsx @@ -0,0 +1,87 @@ +import { TooltipProps } from '@aleph-front/core' +import React from 'react' + +type DisabledMessageProps = { + title: React.ReactNode + description: React.ReactNode +} + +function tooltipContent({ + title, + description, +}: DisabledMessageProps): TooltipProps['content'] { + return ( +
+

{title}

+

{description}

+
+ ) +} + +export function accountConnectionRequiredDisabledMessage( + actionDescription: string, +): TooltipProps['content'] { + return tooltipContent({ + title: `Account connection required`, + description: `Please connect your account to ${actionDescription}. + Connect your wallet using the top-right button to access all features.`, + }) +} + +export function unsupportedHoldingDisabledMessage( + blockchainName: string, +): TooltipProps['content'] { + return tooltipContent({ + title: `Payment Method not supported`, + description: ( + <> + {blockchainName} doesn't support Holder tier payment method. Please + switch the chain using the dropdown at the top of the page. + + ), + }) +} + +export function unsupportedStreamDisabledMessage( + blockchainName: string, +): TooltipProps['content'] { + return tooltipContent({ + title: `Payment Method not supported`, + description: ( + <> + {blockchainName} supports only the Holder tier payment method. To use + the Pay-As-You-Go tier, please switch to the Base or{' '} + Avalanche chain using the dropdown at the top of the + page. + + ), + }) +} + +export function unsupportedStreamManualCRNSelectionDisabledMessage( + blockchainName: string, +): TooltipProps['content'] { + return tooltipContent({ + title: `Manual CRN Selection Unavailable`, + description: ( + <> + Manual selection of CRN is not supported on {blockchainName}. To access + manual CRN selection, please switch to the Base or{' '} + Avalanche chain using the dropdown at the top of the + page. + + ), + }) +} + +export function unsupportedManualCRNSelectionDisabledMessage(): TooltipProps['content'] { + return tooltipContent({ + title: `Feature Unavailable in Holder Tier`, + description: ( + <> + Manual CRN selection is disabled in the Holder tier. Switch to the{' '} + Pay-As-You-Go tier to enable manual selection of CRNs. + + ), + }) +} diff --git a/src/hooks/common/useConnection.ts b/src/hooks/common/useConnection.ts index 09dfc401..d71a6201 100644 --- a/src/hooks/common/useConnection.ts +++ b/src/hooks/common/useConnection.ts @@ -13,6 +13,7 @@ import { BaseConnectionProviderManager } from '@/domain/connect/base' export type UseConnectionProps = { triggerOnMount?: boolean + confirmSwitchModal?: React.ReactNode } export type UseConnectionReturn = ConnectionState & { @@ -48,12 +49,13 @@ export const useConnection = ({ ConnectionConnectAction['payload'] | undefined >('connection', undefined) + // @note: Loads the stored connection in case page is refreshed useEffect(() => { if (!triggerOnMount) return if (!storedConnection) return - handleConnect(storedConnection) - }, [handleConnect, storedConnection, triggerOnMount]) + dispatch(new ConnectionConnectAction(storedConnection)) + }, [dispatch, storedConnection, triggerOnMount]) useEffect(() => { const handleUpdate = (payload: ConnectionUpdateAction['payload']) => { diff --git a/src/hooks/form/useAddSSHKeys.ts b/src/hooks/form/useAddSSHKeys.ts index 21da9592..f2409c14 100644 --- a/src/hooks/form/useAddSSHKeys.ts +++ b/src/hooks/form/useAddSSHKeys.ts @@ -20,6 +20,21 @@ export const defaultValues: SSHKeyField = { isNew: true, } +export function useAccountSSHKeyItems(): SSHKeyField[] { + const { entities: accountSSHKeys } = useRequestSSHKeys() + + return useMemo( + () => + (accountSSHKeys || []).map(({ key, label = '' }, i) => ({ + key, + label, + isSelected: i === 0, + isNew: false, + })), + [accountSSHKeys], + ) +} + export type UseSSHKeyItemProps = { name?: string index: number @@ -116,22 +131,10 @@ export function useAddSSHKeys({ const { remove: handleRemove, append, replace, prepend } = sshKeysCtrl const fields = sshKeysCtrl.fields as (SSHKeyField & { id: string })[] - - const { entities: accountSSHKeys } = useRequestSSHKeys() - - const accountSSHKeyItems: SSHKeyField[] = useMemo( - () => - (accountSSHKeys || []).map(({ key, label = '' }, i) => ({ - key, - label, - isSelected: i === 0, - isNew: false, - })), - [accountSSHKeys], - ) + const accountSSHKeyItems = useAccountSSHKeyItems() // Empty when the account changes - useEffect(() => handleRemove(), [handleRemove, accountSSHKeys]) + useEffect(() => handleRemove(), [handleRemove, accountSSHKeyItems]) useEffect(() => { let newValues = accountSSHKeyItems diff --git a/src/hooks/form/useSelectPaymentMethod.ts b/src/hooks/form/useSelectPaymentMethod.ts index d1515437..485d871b 100644 --- a/src/hooks/form/useSelectPaymentMethod.ts +++ b/src/hooks/form/useSelectPaymentMethod.ts @@ -1,5 +1,5 @@ import { PaymentMethod } from '@/helpers/constants' -import { ChangeEvent, useCallback } from 'react' +import { ChangeEvent, useCallback, useRef } from 'react' import { Control, UseControllerReturn, useController } from 'react-hook-form' export type UseSelectPaymentMethodProps = { @@ -8,6 +8,7 @@ export type UseSelectPaymentMethodProps = { defaultValue?: PaymentMethod disabledHold?: boolean disabledStream?: boolean + disabledStreamTooltip: React.ReactNode onSwitch?: (e: PaymentMethod) => void } @@ -15,6 +16,8 @@ export type UseSelectPaymentMethodReturn = { disabledHold?: boolean disabledStream?: boolean paymentMethodCtrl: UseControllerReturn + disabledStreamTooltip: React.ReactNode + switchRef: React.Ref handleClickStream: () => void handleClickHold: () => void } @@ -24,6 +27,7 @@ export function useSelectPaymentMethod({ control, defaultValue, disabledHold, + disabledStream, onSwitch, ...rest }: UseSelectPaymentMethodProps): UseSelectPaymentMethodReturn { @@ -48,19 +52,28 @@ export function useSelectPaymentMethod({ paymentMethodCtrl.field.value === PaymentMethod.Stream const handleClickStream = useCallback(() => { + if (disabledStream) return + if (paymentMethodCtrl.field.value === PaymentMethod.Stream) return + onChange(PaymentMethod.Stream) - }, [onChange]) + }, [disabledStream, onChange, paymentMethodCtrl.field.value]) const handleClickHold = useCallback(() => { if (disabledHold) return + if (paymentMethodCtrl.field.value === PaymentMethod.Hold) return + onChange(PaymentMethod.Hold) - }, [disabledHold, onChange]) + }, [disabledHold, onChange, paymentMethodCtrl.field.value]) + + const switchRef = useRef(null) return { paymentMethodCtrl, + disabledHold, + disabledStream, + switchRef, handleClickStream, handleClickHold, - disabledHold, ...rest, } } diff --git a/src/hooks/pages/computing/useNewInstancePage.ts b/src/hooks/pages/computing/useNewInstancePage.ts index 44e9741a..1bd324f6 100644 --- a/src/hooks/pages/computing/useNewInstancePage.ts +++ b/src/hooks/pages/computing/useNewInstancePage.ts @@ -1,6 +1,13 @@ import { useAppState } from '@/contexts/appState' -import { FormEvent, useCallback, useEffect, useMemo } from 'react' -import { useRouter } from 'next/router' +import { + FormEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import Router, { useRouter } from 'next/router' import { createFromEVMAccount, isAccountSupported as isAccountPAYGCompatible, @@ -43,9 +50,18 @@ import { import { EntityAddAction } from '@/store/entity' import { useConnection } from '@/hooks/common/useConnection' import Err from '@/helpers/errors' -import { BlockchainId } from '@/domain/connect/base' +import { BlockchainId, blockchains } from '@/domain/connect/base' import { PaymentConfiguration } from '@/domain/executable' import { EVMAccount } from '@aleph-sdk/evm' +import { isBlockchainHoldingCompatible } from '@/domain/blockchain' +import { ModalCardProps, TooltipProps, useModal } from '@aleph-front/core' +import { + accountConnectionRequiredDisabledMessage, + unsupportedHoldingDisabledMessage, + unsupportedManualCRNSelectionDisabledMessage, + unsupportedStreamManualCRNSelectionDisabledMessage, + unsupportedStreamDisabledMessage, +} from '@/components/pages/computing/NewInstancePage/disabledMessages' export type NewInstanceFormState = NameAndTagsField & { image: InstanceImageField @@ -61,38 +77,54 @@ export type NewInstanceFormState = NameAndTagsField & { streamCost: number } -const specs = { ...getDefaultSpecsOptions(true, PaymentMethod.Stream)[0] } +const defaultSpecs = { + ...getDefaultSpecsOptions(true, PaymentMethod.Stream)[0], +} export const defaultValues: Partial = { ...defaultNameAndTags, image: defaultInstanceImage, - specs, - systemVolumeSize: specs.storage, + specs: defaultSpecs, + systemVolumeSize: defaultSpecs.storage, paymentMethod: PaymentMethod.Hold, streamDuration: defaultStreamDuration, streamCost: Number.POSITIVE_INFINITY, - // sshKeys: [{ ...sshKeyDefaultValues }], } -export type UseNewInstancePage = { +export type Modal = 'node-list' + +export type UseNewInstancePageReturn = { address: string accountBalance: number - isCreateButtonDisabled: boolean + blockchainName: string + manuallySelectCRNDisabled: boolean + manuallySelectCRNDisabledMessage?: TooltipProps['content'] + createInstanceDisabled: boolean + createInstanceDisabledMessage?: TooltipProps['content'] + createInstanceButtonTitle?: string + streamDisabled: boolean + disabledStreamDisabledMessage?: TooltipProps['content'] values: any control: Control errors: FieldErrors node?: CRN lastVersion?: NodeLastVersions nodeSpecs?: CRNSpecs + selectedModal?: Modal + setSelectedModal: (modal?: Modal) => void + selectedNode?: string + setSelectedNode: (hash?: string) => void + modalOpen?: (info: ModalCardProps) => void + modalClose?: () => void + handleManuallySelectCRN: () => void + handleSelectNode: () => void handleSubmit: (e: FormEvent) => Promise - handleSelectNode: (hash?: string) => Promise + handleCloseModal: () => void handleBack: () => void } -export function useNewInstancePage(): UseNewInstancePage { - const router = useRouter() +export function useNewInstancePage(): UseNewInstancePageReturn { const [, dispatch] = useAppState() - const { blockchain, account, @@ -101,35 +133,33 @@ export function useNewInstancePage(): UseNewInstancePage { } = useConnection({ triggerOnMount: false, }) + const modal = useModal() + const modalOpen = modal?.open + const modalClose = modal?.close - // ------------------------- - - const { crn } = router.query - - const handleSelectNode = useCallback( - async (hash?: string) => { - const { crn, ...rest } = router.query - const query = hash ? { ...rest, crn: hash } : rest + const router = useRouter() + const { crn: queryCRN } = router.query - if (crn === query.crn) return false + const hasInitialized = useRef(false) + const nodeRef = useRef(undefined) + const [selectedNode, setSelectedNode] = useState() + const [selectedModal, setSelectedModal] = useState() - return router.replace({ query }) - }, - [router], - ) + // ------------------------- + // Request CRNs specs const { nodes, lastVersion } = useRequestCRNs({}) - const node = useMemo(() => { + // @note: Set node depending on CRN + const node: CRN | undefined = useMemo(() => { if (!nodes) return - return nodes.find((node) => node.hash === crn) - }, [nodes, crn]) + if (!queryCRN) return nodeRef.current - const userNodes = useMemo(() => { - if (!node) return - return [node] - }, [node]) + nodeRef.current = nodes.find((node) => node.hash === queryCRN) + return nodeRef.current + }, [queryCRN, nodes]) + const userNodes = useMemo(() => (node ? [node] : undefined), [node]) const { specs } = useRequestCRNSpecs({ nodes: userNodes }) const nodeSpecs = useMemo(() => { @@ -140,10 +170,10 @@ export function useNewInstancePage(): UseNewInstancePage { }, [specs, node]) // ------------------------- + // Checkout flow const manager = useInstanceManager() const { next, stop } = useCheckoutNotification({}) - const onSubmit = useCallback( async (state: NewInstanceFormState) => { if (!manager) throw Err.ConnectYourWallet @@ -187,7 +217,7 @@ export function useNewInstancePage(): UseNewInstancePage { const instance = { ...state, payment, - node, + node: state.paymentMethod === PaymentMethod.Stream ? node : undefined, } as AddInstance const iSteps = await manager.getAddSteps(instance) @@ -214,7 +244,7 @@ export function useNewInstancePage(): UseNewInstancePage { new EntityAddAction({ name: 'instance', entities: accountInstance }), ) - await router.replace('/') + await Router.replace('/') } finally { await stop() } @@ -227,12 +257,13 @@ export function useNewInstancePage(): UseNewInstancePage { nodeSpecs, handleConnect, dispatch, - router, next, stop, ], ) + // ------------------------- + // Setup form const { control, handleSubmit, @@ -246,10 +277,162 @@ export function useNewInstancePage(): UseNewInstancePage { ), readyDeps: [], }) - const values = useWatch({ control }) as NewInstanceFormState - const { storage } = values.specs - const { systemVolumeSize } = values + const formValues = useWatch({ control }) as NewInstanceFormState + + // ------------------------- + + const { storage } = formValues.specs + const { systemVolumeSize } = formValues + const { cost } = useEntityCost({ + entityType: EntityType.Instance, + props: { + specs: formValues.specs, + volumes: formValues.volumes, + streamDuration: formValues.streamDuration, + paymentMethod: formValues.paymentMethod, + }, + }) + + // ------------------------- + // Memos + + const blockchainName = useMemo(() => { + return blockchain ? blockchains[blockchain]?.name : 'Current network' + }, [blockchain]) + + const disabledStreamDisabledMessage: UseNewInstancePageReturn['disabledStreamDisabledMessage'] = + useMemo(() => { + if (!account) + return accountConnectionRequiredDisabledMessage( + 'enable switching payment methods', + ) + + if ( + !isAccountPAYGCompatible(account) && + formValues.paymentMethod === PaymentMethod.Hold + ) + return unsupportedStreamDisabledMessage(blockchainName) + }, [account, blockchainName, formValues.paymentMethod]) + + const streamDisabled = useMemo(() => { + return !!disabledStreamDisabledMessage + }, [disabledStreamDisabledMessage]) + + const address = useMemo(() => account?.address || '', [account]) + + const manuallySelectCRNDisabledMessage: UseNewInstancePageReturn['manuallySelectCRNDisabledMessage'] = + useMemo(() => { + if (!account) + return accountConnectionRequiredDisabledMessage( + 'manually selecting CRNs', + ) + + if (!isAccountPAYGCompatible(account)) + return unsupportedStreamManualCRNSelectionDisabledMessage( + blockchainName, + ) + + if (formValues.paymentMethod === PaymentMethod.Hold) + return unsupportedManualCRNSelectionDisabledMessage() + }, [account, blockchainName, formValues.paymentMethod]) + + const manuallySelectCRNDisabled = useMemo(() => { + return !!manuallySelectCRNDisabledMessage + }, [manuallySelectCRNDisabledMessage]) + + // Checks if user can afford with current balance + const hasEnoughBalance = useMemo(() => { + if (!account) return false + if (process.env.NEXT_PUBLIC_OVERRIDE_ALEPH_BALANCE === 'true') return true + + return accountBalance >= (cost?.totalCost || Number.MAX_SAFE_INTEGER) + }, [account, accountBalance, cost?.totalCost]) + + const createInstanceDisabledMessage: UseNewInstancePageReturn['createInstanceDisabledMessage'] = + useMemo(() => { + // Checks configuration for PAYG tier + if (formValues.paymentMethod === PaymentMethod.Stream) { + if (!isBlockchainPAYGCompatible(blockchain)) + return unsupportedStreamDisabledMessage(blockchainName) + } + + // Checks configuration for Holder tier + if (formValues.paymentMethod === PaymentMethod.Hold) { + if (!isBlockchainHoldingCompatible(blockchain)) + return unsupportedHoldingDisabledMessage(blockchainName) + } + }, [blockchain, blockchainName, formValues.paymentMethod]) + + const createInstanceButtonTitle: UseNewInstancePageReturn['createInstanceButtonTitle'] = + useMemo(() => { + if (!account) return 'Connect' + if (!hasEnoughBalance) return 'Insufficient ALEPH' + + return 'Create instance' + }, [account, hasEnoughBalance]) + + const createInstanceDisabled = useMemo(() => { + if (createInstanceButtonTitle !== 'Create instance') return true + + return !!createInstanceDisabledMessage + }, [createInstanceButtonTitle, createInstanceDisabledMessage]) + + // ------------------------- + // Handlers + + const handleSelectNode = useCallback(async () => { + setSelectedModal(undefined) + + if (!selectedNode) return + + const { crn: queryCRN, ...rest } = router.query + if (queryCRN === selectedNode) return + + Router.replace({ + query: selectedNode ? { ...rest, crn: selectedNode } : rest, + }) + }, [router.query, selectedNode]) + + const handleManuallySelectCRN = useCallback(() => { + setSelectedModal('node-list') + }, []) + + const handleCloseModal = useCallback(() => { + setSelectedModal(undefined) + }, []) + + const handleBack = () => { + router.push('.') + } + + // ------------------------- + // Effects + + // @note: First time the user loads the page, set payment method to Stream if CRN is present + useEffect(() => { + if (hasInitialized.current) return + if (!router.isReady) return + + hasInitialized.current = true + + if (queryCRN) setValue('paymentMethod', PaymentMethod.Stream) + }, [queryCRN, router.isReady, setValue]) + + // @note: Updates url depending on payment method + useEffect(() => { + if (!node) return + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { crn, ...rest } = Router.query + + Router.replace({ + query: + formValues.paymentMethod === PaymentMethod.Hold + ? { ...rest } + : { ...rest, crn: node.hash }, + }) + }, [node, formValues.paymentMethod]) // @note: Change default System fake volume size when the specs changes useEffect(() => { @@ -264,55 +447,41 @@ export function useNewInstancePage(): UseNewInstancePage { setValue('nodeSpecs', nodeSpecs) }, [nodeSpecs, setValue]) - // @note: Set payment method depending on wallet blockchain network - useEffect(() => { - setValue('paymentMethod', !node ? PaymentMethod.Hold : PaymentMethod.Stream) - }, [node, setValue]) - - // ------------------------- - - const { cost } = useEntityCost({ - entityType: EntityType.Instance, - props: { - specs: values.specs, - volumes: values.volumes, - paymentMethod: values.paymentMethod, - streamDuration: values.streamDuration, - }, - }) - // @note: Set streamCost useEffect(() => { if (!cost) return - if (values.streamCost === cost.totalStreamCost) return + if (formValues.streamCost === cost.totalStreamCost) return setValue('streamCost', cost.totalStreamCost) - }, [cost, setValue, values]) - - const canAfford = - accountBalance >= (cost?.totalCost || Number.MAX_SAFE_INTEGER) - let isCreateButtonDisabled = !canAfford - if (process.env.NEXT_PUBLIC_OVERRIDE_ALEPH_BALANCE === 'true') { - isCreateButtonDisabled = false - } - // ------------------------- - - const handleBack = () => { - router.push('.') - } + }, [cost, setValue, formValues]) return { - address: account?.address || '', + address, accountBalance, - isCreateButtonDisabled, - values, + blockchainName, + createInstanceDisabled, + createInstanceDisabledMessage, + createInstanceButtonTitle, + manuallySelectCRNDisabled, + manuallySelectCRNDisabledMessage, + values: formValues, control, errors, node, lastVersion, nodeSpecs, - handleSubmit, + streamDisabled, + disabledStreamDisabledMessage, + selectedModal, + setSelectedModal, + selectedNode, + setSelectedNode, + modalOpen, + modalClose, + handleManuallySelectCRN, handleSelectNode, + handleSubmit, + handleCloseModal, handleBack, } } diff --git a/src/store/connection.ts b/src/store/connection.ts index d42e2654..0c8c87ba 100644 --- a/src/store/connection.ts +++ b/src/store/connection.ts @@ -88,7 +88,7 @@ export function getConnectionReducer(): ConnectionReducer { case ConnectionActionType.CONNECTION_CONNECT: case ConnectionActionType.CONNECTION_UPDATE: { - const { provider: initialProvider, blockchain: initialBlockchain } = + const { provider: currentProvider, blockchain: currentBlockchain } = state const { provider, blockchain } = action.payload @@ -96,13 +96,13 @@ export function getConnectionReducer(): ConnectionReducer { let newBalance = (action as ConnectionUpdateAction).payload.balance || state.balance - // If we are switching between EVM and Solana, we need to hardcode the provider - if (initialProvider) { + // If we are switching between EVM and Solana, hardcode the provider + if (currentProvider) { const isSwitchingToSolana = - initialBlockchain !== BlockchainId.SOL && + currentBlockchain !== BlockchainId.SOL && blockchain === BlockchainId.SOL const isSwitchingToEVM = - initialBlockchain === BlockchainId.SOL && + currentBlockchain === BlockchainId.SOL && blockchain !== BlockchainId.SOL newProvider = @@ -111,8 +111,8 @@ export function getConnectionReducer(): ConnectionReducer { : newProvider } - // If we are switching blockchains, we need to reset the balance - if (initialBlockchain && initialBlockchain !== blockchain) + // If we are switching blockchains, reset the balance + if (currentBlockchain && currentBlockchain !== blockchain) newBalance = undefined return {