Files
websites/apps/staking/components/StakedNode/NodeRequestExitButtonWithDialog.tsx

268 lines
9.0 KiB
TypeScript

import { CollapsableContent } from '@/components/NodeCard';
import NodeActionModuleInfo from '@/components/StakedNode/NodeActionModuleInfo';
import { WalletInteractionButtonWithLocales } from '@/components/WalletInteractionButtonWithLocales';
import { WizardSectionDescription } from '@/components/Wizard';
import useRequestNodeExit from '@/hooks/useRequestNodeExit';
import { SESSION_NODE_TIME, SOCIALS } from '@/lib/constants';
import { REMOTE_FEATURE_FLAG } from '@/lib/feature-flags';
import { useRemoteFeatureFlagQuery } from '@/lib/feature-flags-client';
import { formatEnglishTimeDistance, formatLocalizedTimeFromSeconds } from '@/lib/locale-client';
import { ButtonDataTestId } from '@/testing/data-test-ids';
import type { Stake } from '@session/staking-api-js/schema';
import { Social } from '@session/ui/components/SocialLinkList';
import { Loading } from '@session/ui/components/loading';
import { ChevronsDownIcon } from '@session/ui/icons/ChevronsDownIcon';
import { cn } from '@session/ui/lib/utils';
import { PROGRESS_STATUS, Progress } from '@session/ui/motion/progress';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTrigger,
} from '@session/ui/ui/alert-dialog';
import { Button } from '@session/ui/ui/button';
import { useWallet } from '@session/wallet/hooks/useWallet';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { type HTMLAttributes, type ReactNode, forwardRef, useState } from 'react';
enum EXIT_REQUEST_STATE {
ALERT = 0,
PENDING = 1,
}
export const NodeRequestExitButton = forwardRef<
HTMLSpanElement,
HTMLAttributes<HTMLSpanElement> & {
disabled?: boolean;
}
>(({ disabled, className, ...props }, ref) => {
const dictionary = useTranslations('nodeCard.staked.requestExit');
return (
<CollapsableContent
className={cn('end-6 bottom-4 flex items-end min-[500px]:absolute', className)}
size="buttonSm"
width="w-max"
{...props}
ref={ref}
>
<Button
aria-label={dictionary('buttonAria')}
data-testid={ButtonDataTestId.Staked_Node_Request_Exit}
disabled={disabled}
rounded="md"
size="sm"
variant="destructive-outline"
className="uppercase"
>
{dictionary('buttonText')}
</Button>
</CollapsableContent>
);
});
export function NodeRequestExitButtonWithDialog({
node,
disabled,
}: { node: Stake; disabled?: boolean }) {
const [exitRequestState, setExitRequestState] = useState<EXIT_REQUEST_STATE>(
EXIT_REQUEST_STATE.ALERT
);
const dictionary = useTranslations('nodeCard.staked.requestExit');
const { enabled: isNodeExitRequestDisabled, isLoading: isRemoteFlagLoading } =
useRemoteFeatureFlagQuery(REMOTE_FEATURE_FLAG.DISABLE_REQUEST_NODE_EXIT);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<NodeRequestExitButton disabled={disabled} />
</AlertDialogTrigger>
<AlertDialogContent
dialogTitle={
<>
{exitRequestState !== EXIT_REQUEST_STATE.ALERT ? (
<ChevronsDownIcon
className="absolute left-8 mt-1.5 rotate-90 cursor-pointer rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
onClick={() => setExitRequestState(EXIT_REQUEST_STATE.ALERT)}
/>
) : null}
{dictionary('dialog.title')}
</>
}
className="text-center"
>
{isRemoteFlagLoading ? (
<Loading />
) : isNodeExitRequestDisabled ? (
<RequestNodeExitDisabled />
) : exitRequestState === EXIT_REQUEST_STATE.PENDING ? (
<RequestNodeExitContractWriteDialog node={node} />
) : (
<RequestNodeExitDialog
node={node}
onSubmit={() => setExitRequestState(EXIT_REQUEST_STATE.PENDING)}
/>
)}
</AlertDialogContent>
</AlertDialog>
);
}
function RequestNodeExitDisabled() {
const dictionary = useTranslations('nodeCard.staked.requestExit');
return (
<p>
{dictionary.rich('disabledInfo', {
link: (children: ReactNode) => (
<Link
className="font-medium text-session-green underline"
href={SOCIALS[Social.Discord].link}
referrerPolicy="no-referrer"
target="_blank"
>
{children}
</Link>
),
})}
</p>
);
}
function RequestNodeExitDialog({ node, onSubmit }: { node: Stake; onSubmit: () => void }) {
const { chainId } = useWallet();
const dictionary = useTranslations('nodeCard.staked.requestExit.dialog');
const dictInfoNotice = useTranslations('infoNotice');
return (
<>
<WizardSectionDescription
className="text-base md:text-base"
description={dictInfoNotice.rich('requestExit1', {
relativeRequestTime: formatLocalizedTimeFromSeconds(
SESSION_NODE_TIME(chainId).EXIT_REQUEST_TIME_SECONDS,
{
addSuffix: true,
}
),
requestTime: formatEnglishTimeDistance(
SESSION_NODE_TIME(chainId).EXIT_REQUEST_TIME_SECONDS,
'-'
),
exitTime: formatEnglishTimeDistance(
SESSION_NODE_TIME(chainId).EXIT_GRACE_TIME_SECONDS,
'-'
),
linkOut: '',
})}
href="https://docs.getsession.org/contribute-to-the-session-network/frequently-asked-questions-faq#liquidation-penalty"
/>
<br />
<p>{dictInfoNotice.rich('requestExit2')}</p>
<AlertDialogFooter className="mt-4 flex w-full flex-col font-medium sm:flex-row">
<Button
variant="destructive-ghost"
rounded="md"
size="lg"
aria-label={dictionary('buttons.submitAria', {
pubKey: node.service_node_pubkey,
})}
className="w-full"
data-testid={ButtonDataTestId.Staked_Node_Request_Exit_Dialog_Submit}
onClick={onSubmit}
type="submit"
>
{dictionary('buttons.submit')}
</Button>
<AlertDialogCancel asChild>
<Button
variant="ghost"
rounded="md"
size="lg"
className="w-full"
aria-label={dictionary('buttons.cancelAria')}
data-testid={ButtonDataTestId.Staked_Node_Request_Exit_Dialog_Cancel}
>
{dictionary('buttons.cancel')}
</Button>
</AlertDialogCancel>
</AlertDialogFooter>
</>
);
}
function RequestNodeExitContractWriteDialog({ node }: { node: Stake }) {
const stageDictKey = 'nodeCard.staked.requestExit.dialog.stage' as const;
const dictionary = useTranslations('nodeCard.staked.requestExit.dialog.write');
const dictionaryStage = useTranslations(stageDictKey);
const {
initiateRemoveBLSPublicKey,
fee,
gasAmount,
gasPrice,
simulateEnabled,
resetContract,
status,
errorMessage,
} = useRequestNodeExit({
contractId: node.contract_id,
});
const handleClick = () => {
if (simulateEnabled) {
resetContract();
}
initiateRemoveBLSPublicKey();
};
const isDisabled = !node.contract_id;
return (
<>
<NodeActionModuleInfo node={node} fee={fee} gasAmount={gasAmount} gasPrice={gasPrice} />
<AlertDialogFooter className="mt-4 flex flex-col gap-8 sm:flex-col">
<WalletInteractionButtonWithLocales
variant="destructive"
rounded="md"
size="lg"
aria-label={dictionary('buttons.submitAria')}
className="w-full"
data-testid={ButtonDataTestId.Staked_Node_Request_Exit_Write_Dialog_Submit}
disabled={isDisabled || (simulateEnabled && status !== PROGRESS_STATUS.ERROR)}
onClick={handleClick}
>
{dictionary('buttons.submit')}
</WalletInteractionButtonWithLocales>
{simulateEnabled ? (
<Progress
steps={[
{
text: {
[PROGRESS_STATUS.IDLE]: dictionaryStage('arbitrum.idle'),
[PROGRESS_STATUS.PENDING]: dictionaryStage('arbitrum.pending'),
[PROGRESS_STATUS.SUCCESS]: dictionaryStage('arbitrum.success'),
[PROGRESS_STATUS.ERROR]: errorMessage,
},
status,
},
{
text: {
[PROGRESS_STATUS.IDLE]: dictionaryStage('network.idle'),
[PROGRESS_STATUS.PENDING]: dictionaryStage('network.pending'),
[PROGRESS_STATUS.SUCCESS]: dictionaryStage('network.success'),
[PROGRESS_STATUS.ERROR]: errorMessage,
},
status:
status === PROGRESS_STATUS.SUCCESS
? PROGRESS_STATUS.SUCCESS
: PROGRESS_STATUS.IDLE,
},
]}
/>
) : null}
</AlertDialogFooter>
</>
);
}