vega-frontend-monorepo/apps/trading/client-pages/referrals/apply-code-form.tsx

378 lines
11 KiB
TypeScript

import {
Input,
InputError,
Loader,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import type { FieldValues } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import classNames from 'classnames';
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { RainbowButton } from './buttons';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { useIsInReferralSet, useReferral } from './hooks/use-referral';
import { Routes } from '../../lib/links';
import { useTransactionEventSubscription } from '@vegaprotocol/web3';
import { Statistics, useStats } from './referral-statistics';
import { useReferralProgram } from './hooks/use-referral-program';
import { ns, useT } from '../../lib/use-t';
import { useFundsAvailable } from './hooks/use-funds-available';
import { ViewType, useSidebar } from '../../components/sidebar';
import { useGetCurrentRouteId } from '../../lib/hooks/use-get-current-route-id';
import { QUSDTooltip } from './qusd-tooltip';
import { Trans } from 'react-i18next';
const RELOAD_DELAY = 3000;
const SPAM_PROTECTION_ERR = 'SPAM_PROTECTION_ERR';
const SpamProtectionErr = ({
requiredFunds,
}: {
requiredFunds?: string | number | bigint;
}) => {
if (!requiredFunds) return null;
// eslint-disable-next-line react/jsx-no-undef
return (
<Trans
defaults="To protect the network from spam, you must have at least {{requiredFunds}} <0>qUSD</0> of any asset on the network to proceed."
values={{
requiredFunds,
}}
components={[<QUSDTooltip key="qusd" />]}
ns={ns}
/>
);
};
const validateCode = (value: string, t: ReturnType<typeof useT>) => {
const number = +`0x${value}`;
if (!value || value.length !== 64) {
return t('Code must be 64 characters in length');
} else if (Number.isNaN(number)) {
return t('Code must be be valid hex');
}
return true;
};
export const ApplyCodeFormContainer = ({
onSuccess,
}: {
onSuccess?: () => void;
}) => {
const { pubKey } = useVegaWallet();
const isInReferralSet = useIsInReferralSet(pubKey);
// Navigate to the index page when already in the referral set.
if (isInReferralSet) {
return <Navigate to={Routes.REFERRALS} />;
}
return <ApplyCodeForm onSuccess={onSuccess} />;
};
export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
const t = useT();
const program = useReferralProgram();
const navigate = useNavigate();
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const [status, setStatus] = useState<
'requested' | 'no-funds' | 'successful' | null
>(null);
const txHash = useRef<string | null>(null);
const { isReadOnly, pubKey, sendTx } = useVegaWallet();
const { isEligible, requiredFunds } = useFundsAvailable();
const currentRouteId = useGetCurrentRouteId();
const setViews = useSidebar((s) => s.setViews);
const {
register,
handleSubmit,
formState: { errors },
setValue,
setError,
watch,
} = useForm();
const [params] = useSearchParams();
const codeField = watch('code');
const { data: previewData, loading: previewLoading } = useReferral({
code: validateCode(codeField, t) ? codeField : undefined,
});
/**
* Validates if a connected party can apply a code (min funds span protection)
*/
const validateFundsAvailable = useCallback(() => {
if (requiredFunds && !isEligible) {
const err = SPAM_PROTECTION_ERR;
return err;
}
return true;
}, [isEligible, requiredFunds]);
/**
* Validates the set a user tries to apply to.
*/
const validateSet = useCallback(() => {
if (
codeField &&
!previewLoading &&
previewData &&
!previewData.isEligible
) {
return t('The code is no longer valid.');
}
if (codeField && !previewLoading && !previewData) {
return t('The code is invalid');
}
return true;
}, [codeField, previewData, previewLoading, t]);
useEffect(() => {
const code = params.get('code');
if (code) setValue('code', code);
}, [params, setValue]);
useEffect(() => {
const err = validateFundsAvailable();
if (err !== true) {
setStatus('no-funds');
} else {
setStatus(null);
}
}, [isEligible, validateFundsAvailable]);
const onSubmit = ({ code }: FieldValues) => {
if (isReadOnly || !pubKey || !code || code.length === 0) {
return;
}
setStatus('requested');
sendTx(pubKey, {
applyReferralCode: {
id: code as string,
},
})
.then((res) => {
if (!res) {
setError('code', {
type: 'required',
message: t('The transaction could not be sent'),
});
}
if (res) {
txHash.current = res.transactionHash.toLowerCase();
}
})
.catch((err) => {
if (err.message.includes('user rejected')) {
setStatus(null);
} else {
setStatus(null);
setError('code', {
type: 'required',
message:
err instanceof Error
? err.message
: t('Your code has been rejected'),
});
}
});
};
useTransactionEventSubscription({
variables: { partyId: pubKey || '' },
skip: !pubKey,
fetchPolicy: 'no-cache',
onData: ({ data: result }) =>
result.data?.busEvents?.forEach((event) => {
if (event.event.__typename === 'TransactionResult') {
const hash = event.event.hash.toLowerCase();
if (txHash.current && txHash.current === hash) {
const err = event.event.error;
const status = event.event.status;
if (err) {
setStatus(null);
setError('code', {
type: 'required',
message: err,
});
}
if (status && !err) {
setStatus('successful');
}
}
}
}),
});
const { epochsValue, nextBenefitTierValue } = useStats({ program });
// go to main page when successfully applied
useEffect(() => {
if (status === 'successful') {
setTimeout(() => {
if (onSuccess) onSuccess();
navigate(Routes.REFERRALS);
}, RELOAD_DELAY);
}
}, [navigate, onSuccess, status]);
// show "code applied" message when successfully applied
if (status === 'successful') {
return (
<div className="mx-auto w-1/2">
<h3 className="calt mb-5 flex flex-row items-center justify-center gap-2 text-center text-xl uppercase">
<span className="text-vega-green-500">
<VegaIcon name={VegaIconNames.TICK} size={20} />
</span>{' '}
<span className="pt-1">{t('Code applied')}</span>
</h3>
</div>
);
}
const getButtonProps = () => {
if (!pubKey) {
return {
disabled: false,
children: t('Connect wallet'),
type: 'button' as ButtonHTMLAttributes<HTMLButtonElement>['type'],
onClick: ((event) => {
event.preventDefault();
openWalletDialog();
}) as MouseEventHandler,
};
}
if (isReadOnly) {
return {
disabled: true,
children: t('Apply a code'),
type: 'submit' as ButtonHTMLAttributes<HTMLButtonElement>['type'],
};
}
if (status === 'no-funds') {
return {
disabled: false,
children: t('Deposit funds'),
type: 'button' as ButtonHTMLAttributes<HTMLButtonElement>['type'],
onClick: ((event) => {
event.preventDefault();
setViews({ type: ViewType.Deposit }, currentRouteId);
}) as MouseEventHandler,
};
}
if (status === 'requested') {
return {
disabled: true,
children: t('Confirm in wallet...'),
type: 'submit' as ButtonHTMLAttributes<HTMLButtonElement>['type'],
};
}
return {
disabled: false,
children: t('Apply a code'),
type: 'submit' as ButtonHTMLAttributes<HTMLButtonElement>['type'],
};
};
const nextBenefitTierEpochsValue = nextBenefitTierValue
? nextBenefitTierValue.epochs - epochsValue
: 0;
return (
<>
<div
data-testid="referral-apply-code-form"
className="bg-vega-clight-800 dark:bg-vega-cdark-800 mx-auto w-2/3 max-w-md rounded-lg p-8"
>
<h3 className="calt mb-4 text-center text-2xl">
{t('Apply a referral code')}
</h3>
<p className="mb-4 text-center text-base">
{t(
'Apply a referral code to access the discount benefits of the current program.'
)}
</p>
<form
className={classNames('flex w-full flex-col gap-4', {
'animate-shake': Boolean(errors.code),
})}
onSubmit={handleSubmit(onSubmit)}
>
<label>
<span className="sr-only">{t('Your referral code')}</span>
<Input
hasError={Boolean(errors.code)}
{...register('code', {
required: t('You have to provide a code to apply it.'),
validate: (value) => {
const codeErr = validateCode(value, t);
if (codeErr !== true) return codeErr;
const fundsErr = validateFundsAvailable();
if (fundsErr !== true) return fundsErr;
return validateSet();
},
})}
placeholder="Enter a code"
className="bg-vega-clight-900 dark:bg-vega-cdark-700 mb-2"
/>
</label>
<RainbowButton variant="border" {...getButtonProps()} />
</form>
{status === 'no-funds' ? (
<InputError intent="warning" className="overflow-auto break-words">
<span>
<SpamProtectionErr requiredFunds={requiredFunds?.toString()} />
</span>
</InputError>
) : (
errors.code && (
<InputError intent="warning" className="overflow-auto break-words">
{errors.code.message === SPAM_PROTECTION_ERR ? (
<span>
<SpamProtectionErr
requiredFunds={requiredFunds?.toString()}
/>
</span>
) : (
errors.code.message?.toString()
)}
</InputError>
)
)}
</div>
{validateCode(codeField, t) === true && previewLoading && !previewData ? (
<div className="mt-10">
<Loader />
</div>
) : null}
{/* TODO: Re-check plural forms once i18n is updated */}
{previewData && previewData.isEligible ? (
<div className="mt-10">
<h2 className="mb-5 text-2xl">
{t(
'youAreJoiningTheGroup',
'You are joining the group shown, but will not have access to benefits until you have completed at least {{count}} epochs.',
{ count: nextBenefitTierEpochsValue }
)}
</h2>
<Statistics data={previewData} program={program} as="referee" />
</div>
) : null}
</>
);
};