feat(trading): referrals upgrades

This commit is contained in:
asiaznik 2024-03-08 19:24:49 +01:00
parent 93643f1737
commit f93ac8d0c6
No known key found for this signature in database
GPG Key ID: 4D5C8972A02C52C2
17 changed files with 1447 additions and 841 deletions

View File

@ -16,9 +16,7 @@ import {
useVegaWallet,
useDialogStore,
} from '@vegaprotocol/wallet-react';
import { useIsInReferralSet, useReferral } from './hooks/use-referral';
import { Routes } from '../../lib/links';
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';
@ -26,6 +24,12 @@ 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';
import { PreviewRefereeStatistics } from './referee-statistics';
import {
useReferralSet,
useIsInReferralSet,
} from './hooks/use-find-referral-set';
import minBy from 'lodash/minBy';
const RELOAD_DELAY = 3000;
@ -106,9 +110,11 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
const codeField = watch('code');
const { data: previewData, loading: previewLoading } = useReferral({
code: validateCode(codeField, t) ? codeField : undefined,
});
const {
data: previewData,
loading: previewLoading,
isEligible: isPreviewEligible,
} = useReferralSet(validateCode(codeField, t) ? codeField : undefined);
const { send, status } = useSimpleTransaction({
onSuccess: () => {
@ -141,19 +147,14 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
* Validates the set a user tries to apply to.
*/
const validateSet = useCallback(() => {
if (
codeField &&
!previewLoading &&
previewData &&
!previewData.isEligible
) {
if (codeField && !previewLoading && previewData && !isPreviewEligible) {
return t('The code is no longer valid.');
}
if (codeField && !previewLoading && !previewData) {
return t('The code is invalid');
}
return true;
}, [codeField, previewData, previewLoading, t]);
}, [codeField, isPreviewEligible, previewData, previewLoading, t]);
const noFunds = validateFundsAvailable() !== true ? true : false;
@ -200,8 +201,6 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
// });
};
const { epochsValue, nextBenefitTierValue } = useStats({ program });
// show "code applied" message when successfully applied
if (status === 'confirmed') {
return (
@ -264,9 +263,10 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
};
};
const nextBenefitTierEpochsValue = nextBenefitTierValue
? nextBenefitTierValue.epochs - epochsValue
: 0;
// calculate minimum amount of epochs a referee has to be in a set in order
// to benefit from it
const firstBenefitTier = minBy(program.benefitTiers, (bt) => bt.epochs);
const minEpochs = firstBenefitTier ? firstBenefitTier.epochs : 0;
return (
<>
@ -335,17 +335,17 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
<Loader />
</div>
) : null}
{/* TODO: Re-check plural forms once i18n is updated */}
{previewData && previewData.isEligible ? (
{previewData && isPreviewEligible ? (
<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 }
{ count: minEpochs }
)}
</h2>
<Statistics data={previewData} program={program} as="referee" />
<PreviewRefereeStatistics setId={codeField} />
</div>
) : null}
</>

View File

@ -1,3 +1,6 @@
import { type ApolloError } from '@apollo/client';
import { getUserLocale } from '@vegaprotocol/utils';
export const BORDER_COLOR = 'border-vega-clight-500 dark:border-vega-cdark-500';
export const GRADIENT =
'bg-gradient-to-b from-vega-clight-800 dark:from-vega-cdark-800 to-transparent';
@ -8,3 +11,19 @@ export const REFERRAL_DOCS_LINK =
export const ABOUT_REFERRAL_DOCS_LINK =
'https://docs.vega.xyz/mainnet/concepts/trading-on-vega/discounts-rewards#referral-program';
export const DISCLAIMER_REFERRAL_DOCS_LINK = 'https://docs.vega.xyz/';
export const DEFAULT_AGGREGATION_DAYS = 30;
export type StatValue<T> = {
value: T;
loading: boolean;
error?: ApolloError | Error;
};
export const COMPACT_NUMBER_FORMAT = (maximumFractionDigits = 2) =>
new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits,
notation: 'compact',
compactDisplay: 'short',
});

View File

@ -16,13 +16,16 @@ import {
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import { DApp, TokenStaticLinks, useLinks } from '@vegaprotocol/environment';
import { ABOUT_REFERRAL_DOCS_LINK } from './constants';
import { useIsInReferralSet, useReferral } from './hooks/use-referral';
import { useT } from '../../lib/use-t';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Links, Routes } from '../../lib/links';
import { useReferralProgram } from './hooks/use-referral-program';
import { useReferralSetTransaction } from '../../lib/hooks/use-referral-set-transaction';
import { Trans } from 'react-i18next';
import {
useFindReferralSet,
useIsInReferralSet,
} from './hooks/use-find-referral-set';
export const CreateCodeContainer = () => {
const t = useT();
@ -145,7 +148,7 @@ const CreateCodeDialog = ({
const t = useT();
const createLink = useLinks(DApp.Governance);
const { pubKey } = useVegaWallet();
const { refetch } = useReferral({ pubKey, role: 'referrer' });
const { refetch } = useFindReferralSet(pubKey);
const {
err,
code,

View File

@ -0,0 +1,122 @@
import { useCallback } from 'react';
import {
type ReferralSetsQueryVariables,
useReferralSetsQuery,
} from './__generated__/ReferralSets';
import { useStakeAvailable } from '../../../lib/hooks/use-stake-available';
export type Role = 'referrer' | 'referee';
type Args = (
| { setId: string | undefined }
| { pubKey: string | undefined; role: Role }
) & {
aggregationEpochs?: number;
};
export const prepareVariables = (
args: Args
): [ReferralSetsQueryVariables, boolean] => {
const byId = 'setId' in args;
const byRole = 'pubKey' in args && 'role' in args;
let variables = {};
let skip = true;
if (byId) {
variables = {
id: args.setId,
};
skip = !args.setId;
}
if (byRole) {
if (args.role === 'referee') {
variables = { referee: args.pubKey };
}
if (args.role === 'referrer') {
variables = { referrer: args.pubKey };
}
skip = !args.pubKey;
}
return [variables, skip];
};
export const useFindReferralSet = (pubKey?: string) => {
const [referrerVariables, referrerSkip] = prepareVariables({
pubKey,
role: 'referrer',
});
const [refereeVariables, refereeSkip] = prepareVariables({
pubKey,
role: 'referee',
});
const {
data: referrerData,
loading: referrerLoading,
error: referrerError,
refetch: referrerRefetch,
} = useReferralSetsQuery({
variables: referrerVariables,
skip: referrerSkip,
fetchPolicy: 'cache-and-network',
});
const {
data: refereeData,
loading: refereeLoading,
error: refereeError,
refetch: refereeRefetch,
} = useReferralSetsQuery({
variables: refereeVariables,
skip: refereeSkip,
fetchPolicy: 'cache-and-network',
});
const set =
referrerData?.referralSets.edges[0]?.node ||
refereeData?.referralSets.edges[0]?.node;
const role: Role | undefined = set
? set?.referrer === pubKey
? 'referrer'
: 'referee'
: undefined;
const { isEligible } = useStakeAvailable(set?.referrer);
const refetch = useCallback(() => {
referrerRefetch();
refereeRefetch();
}, [refereeRefetch, referrerRefetch]);
return {
data: set,
role,
loading: referrerLoading || refereeLoading,
error: referrerError || refereeError,
refetch,
isEligible: set ? isEligible : undefined,
};
};
export const useReferralSet = (setId?: string) => {
const [variables, skip] = prepareVariables({ setId });
const { data, loading, error, refetch } = useReferralSetsQuery({
variables,
skip,
fetchPolicy: 'cache-and-network',
});
const set = data?.referralSets.edges[0]?.node;
const { isEligible } = useStakeAvailable(set?.referrer);
return {
data: set,
loading,
error,
refetch,
isEligible: set ? isEligible : undefined,
};
};
export const useIsInReferralSet = (pubKey: string | undefined) => {
const { data } = useFindReferralSet(pubKey);
return Boolean(data);
};

View File

@ -0,0 +1,117 @@
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { useReferralSetStatsQuery } from './__generated__/ReferralSetStats';
import { findReferee, useReferees } from './use-referees';
import BigNumber from 'bignumber.js';
import { type BenefitTier, useReferralProgram } from './use-referral-program';
import { type StatValue } from '../constants';
import minBy from 'lodash/minBy';
import { useEpochInfoQuery } from '../../../lib/hooks/__generated__/Epoch';
export type RefereeStats = {
/** the discount factor -> `discountFactor` ~ `referralDiscountFactor` */
discountFactor: StatValue<BigNumber>;
/** the benefit tier matching the referee's discount factor */
benefitTier: StatValue<BenefitTier | undefined>;
/** the next benefit tier after the current referee's tier */
nextBenefitTier: StatValue<BenefitTier | undefined>;
/** the running volume */
runningVolume: StatValue<BigNumber>;
/** the number of epochs in set */
epochs: StatValue<BigNumber>;
};
const ZERO = BigNumber(0);
export const useRefereeStats = (
pubKey: string,
setId: string,
aggregationEpochs: number
): RefereeStats => {
const { data, loading, error } = useReferralSetStatsQuery({
variables: {
code: setId,
},
skip: !setId || setId.length === 0 || !pubKey || pubKey.length === 0,
fetchPolicy: 'cache-and-network',
});
const {
benefitTiers,
loading: programLoading,
error: programError,
} = useReferralProgram();
const {
data: epochData,
loading: epochsLoading,
error: epochsError,
} = useEpochInfoQuery({
fetchPolicy: 'network-only',
});
const {
data: refereesData,
loading: refereesLoading,
error: refereesError,
} = useReferees(setId, aggregationEpochs);
const referee = findReferee(pubKey, refereesData);
const stats = removePaginationWrapper(data?.referralSetStats.edges).find(
(s) => s.partyId === pubKey
);
const discountFactor = {
value: stats?.discountFactor ? BigNumber(stats.discountFactor) : ZERO,
loading: loading || refereesLoading,
error: error || refereesError,
};
const benefitTier = {
value: benefitTiers.find(
(t) =>
!discountFactor.value.isNaN() &&
!isNaN(t.discountFactor) &&
t.discountFactor === discountFactor.value.toNumber()
),
loading: programLoading || discountFactor.loading,
error: programError || discountFactor.error,
};
const nextTier = benefitTier.value?.tier
? benefitTier.value.tier + 1
: undefined;
const nextBenefitTier = {
value: nextTier
? benefitTiers.find((t) => t.tier === nextTier)
: minBy(benefitTiers, (t) => t.tier), // min tier number is lowest tier
loading: benefitTier.loading,
error: benefitTier.error,
};
const runningVolume = {
value: stats?.referralSetRunningNotionalTakerVolume
? BigNumber(stats.referralSetRunningNotionalTakerVolume)
: ZERO,
loading,
error,
};
const joinedAtEpoch = BigNumber(referee?.atEpoch || '');
const currentEpoch = BigNumber(epochData?.epoch.id || '');
const epochs = {
value:
!currentEpoch.isNaN() && !joinedAtEpoch.isNaN()
? currentEpoch.minus(joinedAtEpoch)
: ZERO,
loading: refereesLoading || epochsLoading,
error: refereesError || epochsError,
};
return {
discountFactor,
benefitTier,
nextBenefitTier,
runningVolume,
epochs,
};
};

View File

@ -0,0 +1,107 @@
import { type RefereesQuery } from './__generated__/Referees';
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { useRefereesQuery } from './__generated__/Referees';
import { useCallback } from 'react';
import pick from 'lodash/pick';
export type Referee = Omit<
NonNullable<RefereesQuery['referralSetReferees']['edges'][0]>['node'],
'__typename'
>;
/** The properties that can be overwritten by `propertiesOptions`. */
type RefereeProperty = keyof Pick<
Referee,
'totalRefereeGeneratedRewards' | 'totalRefereeNotionalTakerVolume'
>;
/**
* Options determining which properties should be overwritten based
* on the different `aggregationEpochs`.
*/
export type PropertiesWithDifferentAggregationEpochs = {
properties: RefereeProperty[];
aggregationEpochs: number;
};
/** Find referee by its public key (id) */
export const findReferee = (pubKey: string, referees: Referee[]) =>
referees.find((r) => r.refereeId === pubKey);
export const useReferees = (
id: string | undefined | null,
aggregationEpochs: number,
propertiesOptions?: PropertiesWithDifferentAggregationEpochs
) => {
const {
data: refereesData,
loading: refereesLoading,
error: refereesError,
refetch: refereesRefetch,
} = useRefereesQuery({
variables: {
code: id as string,
aggregationEpochs,
},
skip: !id,
fetchPolicy: 'cache-and-network',
context: { isEnlargedTimeout: true },
});
const {
data: extraData,
loading: extraLoading,
error: extraError,
refetch: extraRefetch,
} = useRefereesQuery({
variables: {
code: id as string,
aggregationEpochs: propertiesOptions?.aggregationEpochs,
},
skip:
// skip if the aggregation epochs are the same
!id ||
!propertiesOptions?.aggregationEpochs ||
propertiesOptions.aggregationEpochs === aggregationEpochs,
fetchPolicy: 'cache-and-network',
context: { isEnlargedTimeout: true },
});
let referees = [];
const refereesList = removePaginationWrapper(
refereesData?.referralSetReferees.edges
);
const extraRefereesList = removePaginationWrapper(
extraData?.referralSetReferees.edges
);
referees = refereesList.map((r) =>
overwriteProperties(r, extraRefereesList, propertiesOptions?.properties)
);
const loading = refereesLoading || extraLoading;
const error = refereesError || extraError;
const refetch = useCallback(() => {
refereesRefetch();
extraRefetch();
}, [refereesRefetch, extraRefetch]);
return { data: referees, loading, error, refetch };
};
const overwriteProperties = (
referee: Referee,
referees: Referee[],
properties?: PropertiesWithDifferentAggregationEpochs['properties']
) => {
let updatedProperties = {};
const extraRefereeData = findReferee(referee.refereeId, referees);
if (properties && extraRefereeData) {
updatedProperties = pick(extraRefereeData, properties);
}
return {
...referee,
...updatedProperties,
};
};

View File

@ -1,8 +1,12 @@
import { formatNumber } from '@vegaprotocol/utils';
import sortBy from 'lodash/sortBy';
import omit from 'lodash/omit';
import { useReferralProgramQuery } from './__generated__/CurrentReferralProgram';
import {
type ReferralProgramQuery,
useReferralProgramQuery,
} from './__generated__/CurrentReferralProgram';
import BigNumber from 'bignumber.js';
import { type ApolloError } from '@apollo/client';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const MOCK = {
@ -82,7 +86,37 @@ const MOCK = {
},
};
export const useReferralProgram = () => {
type ProgramDetail = Omit<
NonNullable<ReferralProgramQuery['currentReferralProgram']>,
'benefitTiers' | 'stakingTiers'
>;
export type BenefitTier = {
tier: number;
rewardFactor: number;
commission: string;
discountFactor: number;
discount: string;
minimumVolume: number;
volume: string;
epochs: number;
};
type StakingTier = {
tier: number;
minimumStakedTokens: string;
referralRewardMultiplier: string;
};
export type ReferralProgramData = {
benefitTiers: BenefitTier[];
stakingTiers: StakingTier[];
details: ProgramDetail | undefined;
loading: boolean;
error?: ApolloError;
};
export const useReferralProgram = (): ReferralProgramData => {
const { data, loading, error } = useReferralProgramQuery({
fetchPolicy: 'cache-and-network',
});

View File

@ -5,20 +5,22 @@ import {
ToastHeading,
Button,
} from '@vegaprotocol/ui-toolkit';
import { useReferral } from './use-referral';
import { useVegaWallet } from '@vegaprotocol/wallet-react';
import { useEffect } from 'react';
import { useT } from '../../../lib/use-t';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import { Routes } from '../../../lib/links';
import { useEpochInfoQuery } from '../../../lib/hooks/__generated__/Epoch';
import { useFindReferralSet } from './use-find-referral-set';
const REFETCH_INTERVAL = 60 * 60 * 1000; // 1h
const NON_ELIGIBLE_REFERRAL_SET_TOAST_ID = 'non-eligible-referral-set';
const useNonEligibleReferralSet = () => {
const { pubKey } = useVegaWallet();
const { data, loading, refetch } = useReferral({ pubKey, role: 'referee' });
const { data, loading, role, isEligible, refetch } =
useFindReferralSet(pubKey);
const {
data: epochData,
loading: epochLoading,
@ -36,7 +38,13 @@ const useNonEligibleReferralSet = () => {
};
}, [epochRefetch, refetch]);
return { data, epoch: epochData?.epoch.id, loading: loading || epochLoading };
return {
data,
isEligible,
role,
epoch: epochData?.epoch.id,
loading: loading || epochLoading,
};
};
export const useReferralToasts = () => {
@ -49,14 +57,16 @@ export const useReferralToasts = () => {
store.update,
]);
const { data, epoch, loading } = useNonEligibleReferralSet();
const { data, role, isEligible, epoch, loading } =
useNonEligibleReferralSet();
useEffect(() => {
if (
data &&
role === 'referee' &&
epoch &&
!loading &&
!data.isEligible &&
!isEligible &&
!hasToast(NON_ELIGIBLE_REFERRAL_SET_TOAST_ID + epoch)
) {
const nonEligibleReferralToast: Toast = {
@ -98,9 +108,11 @@ export const useReferralToasts = () => {
data,
epoch,
hasToast,
isEligible,
loading,
navigate,
pathname,
role,
setToast,
t,
updateToast,

View File

@ -1,217 +0,0 @@
import { removePaginationWrapper } from '@vegaprotocol/utils';
import { useCallback } from 'react';
import { useRefereesQuery } from './__generated__/Referees';
import compact from 'lodash/compact';
import pick from 'lodash/pick';
import type {
ReferralSetsQuery,
ReferralSetsQueryVariables,
} from './__generated__/ReferralSets';
import { useReferralSetsQuery } from './__generated__/ReferralSets';
import { useStakeAvailable } from '../../../lib/hooks/use-stake-available';
export const DEFAULT_AGGREGATION_DAYS = 30;
export type Role = 'referrer' | 'referee';
type UseReferralArgs = (
| { code: string | undefined }
| { pubKey: string | undefined; role: Role }
) & {
aggregationEpochs?: number;
};
const prepareVariables = (
args: UseReferralArgs
): [ReferralSetsQueryVariables, boolean] => {
const byCode = 'code' in args;
const byRole = 'pubKey' in args && 'role' in args;
let variables = {};
let skip = true;
if (byCode) {
variables = {
id: args.code,
};
skip = !args.code;
}
if (byRole) {
if (args.role === 'referee') {
variables = { referee: args.pubKey };
}
if (args.role === 'referrer') {
variables = { referrer: args.pubKey };
}
skip = !args.pubKey;
}
return [variables, skip];
};
export const useReferral = (args: UseReferralArgs) => {
const [variables, skip] = prepareVariables(args);
const {
data: referralData,
loading: referralLoading,
error: referralError,
refetch: referralRefetch,
} = useReferralSetsQuery({
variables,
skip,
fetchPolicy: 'cache-and-network',
});
// A user can only have 1 active referral program at a time
const referralSet =
referralData?.referralSets.edges &&
referralData.referralSets.edges.length > 0
? referralData.referralSets.edges[0]?.node
: undefined;
const { isEligible } = useStakeAvailable(referralSet?.referrer);
const {
data: refereesData,
loading: refereesLoading,
error: refereesError,
refetch: refereesRefetch,
} = useRefereesQuery({
variables: {
code: referralSet?.id as string,
aggregationEpochs:
args.aggregationEpochs !== null
? args.aggregationEpochs
: DEFAULT_AGGREGATION_DAYS,
},
skip: !referralSet?.id,
fetchPolicy: 'cache-and-network',
context: { isEnlargedTimeout: true },
});
const referees = compact(
removePaginationWrapper(refereesData?.referralSetReferees.edges)
);
const refetch = useCallback(() => {
referralRefetch();
refereesRefetch();
}, [refereesRefetch, referralRefetch]);
const byReferee =
'role' in args && 'pubKey' in args && args.role === 'referee';
const referee = byReferee
? referees.find((r) => r.refereeId === args.pubKey) || null
: null;
const data =
referralSet && refereesData
? {
code: referralSet.id,
role: 'role' in args ? args.role : null,
referee: referee,
referrerId: referralSet.referrer,
createdAt: referralSet.createdAt,
isEligible,
referees,
}
: undefined;
return {
data,
loading: referralLoading || refereesLoading,
error: referralError || refereesError,
refetch,
};
};
type Referee = NonNullable<
NonNullable<ReturnType<typeof useReferral>['data']>['referee']
>;
type RefereeProperties = (keyof Referee)[];
const findReferee = (referee: Referee, referees: Referee[]) =>
referees.find((r) => r.refereeId === referee?.refereeId) || referee;
const updateReferee = (
referee: Referee,
referees: Referee[],
properties: RefereeProperties
) => ({
...referee,
...pick(findReferee(referee, referees), properties),
});
export const useUpdateReferees = (
referral: ReturnType<typeof useReferral>,
aggregationEpochs: number,
properties: RefereeProperties,
skip?: boolean
): ReturnType<typeof useReferral> => {
const { data, loading, error, refetch } = useRefereesQuery({
variables: {
code: referral?.data?.code as string,
aggregationEpochs,
},
skip: skip || !referral?.data?.code,
fetchPolicy: 'cache-and-network',
context: { isEnlargedTimeout: true },
});
const refetchAll = useCallback(() => {
refetch();
referral.refetch();
}, [refetch, referral]);
if (!referral.data || skip) {
return referral;
}
const referees = compact(
removePaginationWrapper(data?.referralSetReferees.edges)
);
return {
data: data && {
...referral.data,
referees: referral.data.referees.map((referee) =>
updateReferee(referee, referees, properties)
),
referee:
referral.data.referee &&
updateReferee(referral.data.referee, referees, properties),
},
loading: loading || referral.loading,
error: error || referral.error,
refetch: refetchAll,
};
};
const retrieveReferralSetData = (data: ReferralSetsQuery | undefined) =>
data?.referralSets.edges && data.referralSets.edges.length > 0
? data.referralSets.edges[0]?.node
: undefined;
export const useIsInReferralSet = (pubKey: string | undefined) => {
const [asRefereeVariables, asRefereeSkip] = prepareVariables({
pubKey,
role: 'referee',
});
const [asReferrerVariables, asReferrerSkip] = prepareVariables({
pubKey,
role: 'referrer',
});
const { data: asRefereeData } = useReferralSetsQuery({
variables: asRefereeVariables,
skip: asRefereeSkip,
fetchPolicy: 'cache-and-network',
});
const { data: asReferrerData } = useReferralSetsQuery({
variables: asReferrerVariables,
skip: asReferrerSkip,
fetchPolicy: 'cache-and-network',
});
return Boolean(
retrieveReferralSetData(asRefereeData) ||
retrieveReferralSetData(asReferrerData)
);
};

View File

@ -0,0 +1,104 @@
import { useReferralSetStatsQuery } from './__generated__/ReferralSetStats';
import BigNumber from 'bignumber.js';
import { useReferees } from './use-referees';
import { type StatValue } from '../constants';
export type ReferrerStats = {
/** the base commission -> `rewardFactor` ~ `referralRewardFactor` */
baseCommission: StatValue<BigNumber>;
/** the staking multiplier -> `rewardsMultiplier` ~ `referralRewardMultiplier` */
multiplier: StatValue<BigNumber>;
/** the final commission -> base * multiplier */
finalCommission: StatValue<BigNumber>;
/** the referrer taker volume -> `referrerTakerVolume` */
volume: StatValue<BigNumber>;
/** the number of referees -> referees query required */
referees: StatValue<BigNumber>;
/** the total commission -> sum of `totalRefereeGeneratedRewards` */
totalCommission: StatValue<BigNumber>;
runningVolume: StatValue<BigNumber>;
};
const ZERO = BigNumber(0);
const ONE = BigNumber(1);
export const useReferrerStats = (
setId: string,
aggregationEpochs: number
): ReferrerStats => {
const { data, loading, error } = useReferralSetStatsQuery({
variables: {
code: setId,
},
skip: !setId || setId.length === 0,
fetchPolicy: 'cache-and-network',
});
const {
data: refereesData,
loading: refereesLoading,
error: refereesError,
} = useReferees(setId, aggregationEpochs);
const statsAvailable = data?.referralSetStats.edges[0]?.node;
const baseCommission = {
value: statsAvailable ? BigNumber(statsAvailable.rewardFactor) : ZERO,
loading,
error,
};
const multiplier = {
value: statsAvailable ? BigNumber(statsAvailable.rewardsMultiplier) : ONE,
loading,
error,
};
const finalCommission = {
value: !multiplier.value.isNaN()
? baseCommission.value
: new BigNumber(multiplier.value).times(baseCommission.value),
loading,
error,
};
const volume = {
value: statsAvailable
? BigNumber(statsAvailable.referrerTakerVolume)
: ZERO,
loading,
error,
};
const referees = {
value: BigNumber(refereesData.length),
loading: refereesLoading,
error: refereesError,
};
const totalCommission = {
value: refereesData
.map((r) => new BigNumber(r.totalRefereeGeneratedRewards))
.reduce((all, r) => all.plus(r), ZERO),
loading: refereesLoading,
error: refereesError,
};
const runningVolume = {
value: statsAvailable?.referralSetRunningNotionalTakerVolume
? BigNumber(statsAvailable.referralSetRunningNotionalTakerVolume)
: ZERO,
loading,
error,
};
return {
baseCommission,
multiplier,
finalCommission,
volume,
referees,
totalCommission,
runningVolume,
};
};

View File

@ -0,0 +1,202 @@
import classNames from 'classnames';
import { useRefereeStats } from './hooks/use-referee-stats';
import {
BenefitTierTile,
DiscountTile,
EpochsTile,
NextTierEpochsTile,
NextTierVolumeTile,
RunningVolumeTile,
TeamTile,
} from './tiles';
import { useStakeAvailable } from '../../lib/hooks/use-stake-available';
import { CodeTile } from './tile';
import { useT } from '../../lib/use-t';
import { ApplyCodeForm } from './apply-code-form';
import { useVegaWallet } from '@vegaprotocol/wallet-react';
import { useReferralProgram } from './hooks/use-referral-program';
import { DEFAULT_AGGREGATION_DAYS } from './constants';
import { useReferralSet } from './hooks/use-find-referral-set';
import { Loader } from '@vegaprotocol/ui-toolkit';
import minBy from 'lodash/minBy';
import BigNumber from 'bignumber.js';
export const RefereeStatistics = ({
aggregationEpochs,
setId,
pubKey,
referrerPubKey,
}: {
/** The aggregation epochs used to calculate statistics. */
aggregationEpochs: number;
/** The set id (code). */
setId: string;
/** The referee public key. */
pubKey: string;
/** The referrer's public key. */
referrerPubKey: string;
}) => {
const t = useT();
const {
benefitTier,
discountFactor,
epochs,
nextBenefitTier,
runningVolume,
} = useRefereeStats(pubKey, setId, aggregationEpochs);
const { isEligible } = useStakeAvailable(referrerPubKey);
return (
<>
<div
data-testid="referral-statistics"
data-as="referee"
className="relative mx-auto mb-20"
>
<div className={classNames('grid grid-cols-1 grid-rows-1 gap-5')}>
{/** TEAM TILE - referral set id is the same as team id */}
<TeamTile teamId={setId} />
{/** TILES ROW 1 */}
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
<BenefitTierTile
benefitTier={benefitTier}
nextBenefitTier={nextBenefitTier}
/>
<RunningVolumeTile
aggregationEpochs={aggregationEpochs}
runningVolume={runningVolume}
/>
<CodeTile code={setId} />
</div>
{/** TILES ROW 2 */}
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
<DiscountTile discountFactor={discountFactor} />
<NextTierVolumeTile
nextBenefitTier={nextBenefitTier}
runningVolume={runningVolume}
/>
<EpochsTile epochs={epochs} />
<NextTierEpochsTile
epochs={epochs}
nextBenefitTier={nextBenefitTier}
/>
</div>
</div>
{/** ELIGIBILITY WARNING */}
{!isEligible ? (
<div
data-testid="referral-eligibility-warning"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-1/2 lg:w-1/3"
>
<h2 className="text-2xl mb-2">
{t('Referral code no longer valid')}
</h2>
<p>
{t(
'Your referral code is no longer valid as the referrer no longer meets the minimum requirements. Apply a new code to continue receiving discounts.'
)}
</p>
</div>
) : null}
</div>
{!isEligible && <ApplyCodeForm />}
</>
);
};
export const PreviewRefereeStatistics = ({ setId }: { setId: string }) => {
const program = useReferralProgram();
const aggregationEpochs =
program.details?.windowLength || DEFAULT_AGGREGATION_DAYS;
const { pubKey } = useVegaWallet();
const { data: referralSet, loading } = useReferralSet(setId);
const { epochs, runningVolume } = useRefereeStats(
pubKey || '',
referralSet?.id || '',
aggregationEpochs
);
if (loading) {
return (
<div
data-testid="referral-statistics"
data-as="referee"
data-preview
className="relative mx-auto mb-20"
>
<Loader size="small" />
</div>
);
}
if (!referralSet) {
return null;
}
const stat = <T,>(value: T) => ({
value,
loading: false,
error: undefined,
});
const firstBenefitTier = stat(minBy(program.benefitTiers, (bt) => bt.epochs));
const nextBenefitTier = stat(
program.benefitTiers.find(
(bt) =>
bt.tier ===
(firstBenefitTier.value?.tier
? firstBenefitTier.value.tier + 1
: undefined)
)
);
const discountFactor = stat(
firstBenefitTier.value?.discountFactor
? BigNumber(firstBenefitTier.value?.discountFactor)
: BigNumber(0)
);
return (
<div
data-testid="referral-statistics"
data-as="referee"
data-preview
className="relative mx-auto mb-20"
>
<div className={classNames('grid grid-cols-1 grid-rows-1 gap-5')}>
{/** TEAM TILE - referral set id is the same as team id */}
<TeamTile teamId={setId} />
{/** TILES ROW 1 */}
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
<BenefitTierTile
benefitTier={firstBenefitTier}
nextBenefitTier={nextBenefitTier}
/>
<RunningVolumeTile
aggregationEpochs={aggregationEpochs}
runningVolume={runningVolume}
/>
<CodeTile code={setId} />
</div>
{/** TILES ROW 2 */}
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
<DiscountTile discountFactor={discountFactor} />
<NextTierVolumeTile
nextBenefitTier={nextBenefitTier}
runningVolume={runningVolume}
/>
<EpochsTile epochs={epochs} />
<NextTierEpochsTile
epochs={epochs}
nextBenefitTier={nextBenefitTier}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,146 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { ns, useT } from '../../lib/use-t';
import classNames from 'classnames';
import {
Loader,
Tooltip,
VegaIcon,
VegaIconNames,
truncateMiddle,
} from '@vegaprotocol/ui-toolkit';
import { Table } from '../../components/table';
import { formatNumber, getDateTimeFormat } from '@vegaprotocol/utils';
import sortBy from 'lodash/sortBy';
import { Trans } from 'react-i18next';
import { QUSDTooltip } from './qusd-tooltip';
import { type Referee, useReferees } from './hooks/use-referees';
import { DEFAULT_AGGREGATION_DAYS } from './constants';
export const Referees = ({
setId,
aggregationEpochs,
}: {
setId: string;
aggregationEpochs: number;
}) => {
const { data, loading } = useReferees(setId, aggregationEpochs, {
// get total referree generated rewards for the last 30 days
aggregationEpochs: DEFAULT_AGGREGATION_DAYS,
properties: ['totalRefereeGeneratedRewards'],
});
if (loading) {
return <Loader size="small" />;
}
return <RefereesTable data={data} aggregationEpochs={aggregationEpochs} />;
};
export const RefereesTable = ({
data: referees,
aggregationEpochs,
}: {
data: Referee[];
aggregationEpochs: number;
}) => {
const t = useT();
const [collapsed, setCollapsed] = useState(false);
const tableRef = useRef<HTMLTableElement>(null);
useLayoutEffect(() => {
if ((tableRef.current?.getBoundingClientRect().height || 0) > 384) {
setCollapsed(true);
}
}, []);
return (
<>
{/* Referees (only for referrer view) */}
{referees.length > 0 && (
<div className="mt-20 mb-20">
<h2 className="mb-5 text-2xl">{t('Referees')}</h2>
<div
className={classNames(
collapsed && [
'relative max-h-96 overflow-hidden',
'after:w-full after:h-20 after:absolute after:bottom-0 after:left-0',
'after:bg-gradient-to-t after:from-white after:dark:from-vega-cdark-900 after:to-transparent',
]
)}
>
<button
className={classNames(
'absolute left-1/2 bottom-0 z-10 p-2 translate-x-[-50%]',
{
hidden: !collapsed,
}
)}
onClick={() => setCollapsed(false)}
>
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={24} />
</button>
<Table
ref={tableRef}
columns={[
{ name: 'party', displayName: t('Trader') },
{ name: 'joined', displayName: t('Date Joined') },
{
name: 'volume',
displayName: t(
'volumeLastEpochs',
'Volume (last {{count}} epochs)',
{
count: aggregationEpochs,
}
),
},
{
// NOTE: This should be gotten for the last 30 days regardless of the program's window length
name: 'commission',
displayName: (
<Trans
i18nKey="referralStatisticsCommission"
defaults="Commission earned in <0>qUSD</0> (<1>last {{count}} epochs</1>)"
components={[
<QUSDTooltip key="0" />,
<Tooltip
key="1"
description={t(
'Depending on data node retention you may not be able see the full 30 days'
)}
>
<span>last 30 epochs</span>
</Tooltip>,
]}
values={{
count: DEFAULT_AGGREGATION_DAYS,
}}
ns={ns}
/>
),
},
]}
data={sortBy(
referees.map((r) => ({
party: (
<span title={r.refereeId}>
{truncateMiddle(r.refereeId)}
</span>
),
joined: getDateTimeFormat().format(new Date(r.joinedAt)),
volume: Number(r.totalRefereeNotionalTakerVolume),
commission: Number(r.totalRefereeGeneratedRewards),
})),
(r) => r.volume
)
.map((r) => ({
...r,
volume: formatNumber(r.volume, 0),
commission: formatNumber(r.commission, 0),
}))
.reverse()}
/>
</div>
</div>
)}
</>
);
};

View File

@ -1,597 +1,58 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import BigNumber from 'bignumber.js';
import minBy from 'lodash/minBy';
import sortBy from 'lodash/sortBy';
import compact from 'lodash/compact';
import { Trans } from 'react-i18next';
import classNames from 'classnames';
import {
VegaIcon,
VegaIconNames,
truncateMiddle,
TextChildrenTooltip as Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { Loader } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet-react';
import {
addDecimalsFormatNumber,
formatNumber,
getDateFormat,
getDateTimeFormat,
getUserLocale,
removePaginationWrapper,
} from '@vegaprotocol/utils';
import { useReferralSetStatsQuery } from './hooks/__generated__/ReferralSetStats';
import { useStakeAvailable } from '../../lib/hooks/use-stake-available';
import { useT, ns } from '../../lib/use-t';
import { useTeam } from '../../lib/hooks/use-team';
import { TeamAvatar } from '../../components/competitions/team-avatar';
import { TeamStats } from '../../components/competitions/team-stats';
import { Table } from '../../components/table';
import {
DEFAULT_AGGREGATION_DAYS,
useReferral,
useUpdateReferees,
} from './hooks/use-referral';
import { ApplyCodeForm, ApplyCodeFormContainer } from './apply-code-form';
import { ApplyCodeFormContainer } from './apply-code-form';
import { useReferralProgram } from './hooks/use-referral-program';
import { useEpochInfoQuery } from '../../lib/hooks/__generated__/Epoch';
import { QUSDTooltip } from './qusd-tooltip';
import { CodeTile, StatTile, Tile } from './tile';
import { areTeamGames, useGames } from '../../lib/hooks/use-games';
import { useFindReferralSet } from './hooks/use-find-referral-set';
import { Referees } from './referees';
import { ReferrerStatistics } from './referrer-statistics';
import { RefereeStatistics } from './referee-statistics';
import { DEFAULT_AGGREGATION_DAYS } from './constants';
export const ReferralStatistics = () => {
const { pubKey } = useVegaWallet();
const program = useReferralProgram();
const { data: referee, refetch: refereeRefetch } = useReferral({
pubKey,
role: 'referee',
aggregationEpochs: program.details?.windowLength,
});
const {
data: referralSet,
loading: referralSetLoading,
role,
refetch,
} = useFindReferralSet(pubKey);
const { data: referrer, refetch: referrerRefetch } = useUpdateReferees(
useReferral({
pubKey,
role: 'referrer',
aggregationEpochs: program.details?.windowLength,
}),
DEFAULT_AGGREGATION_DAYS,
['totalRefereeGeneratedRewards'],
DEFAULT_AGGREGATION_DAYS === program.details?.windowLength
);
if (referralSetLoading) {
return <Loader size="small" />;
}
const refetch = useCallback(() => {
refereeRefetch();
referrerRefetch();
}, [refereeRefetch, referrerRefetch]);
const aggregationEpochs =
program.details?.windowLength || DEFAULT_AGGREGATION_DAYS;
if (referee?.code) {
if (referralSet?.id && role === 'referrer') {
return (
<>
<Statistics data={referee} program={program} as="referee" />
{!referee.isEligible && <ApplyCodeForm />}
<ReferrerStatistics
aggregationEpochs={aggregationEpochs}
createdAt={referralSet.createdAt}
setId={referralSet.id}
/>
<Referees
setId={referralSet.id}
aggregationEpochs={aggregationEpochs}
/>
</>
);
}
if (referrer?.code) {
if (pubKey && referralSet?.id && role === 'referee') {
return (
<>
<Statistics data={referrer} program={program} as="referrer" />
<RefereesTable data={referrer} program={program} />
</>
<RefereeStatistics
aggregationEpochs={aggregationEpochs}
pubKey={pubKey}
referrerPubKey={referralSet.referrer}
setId={referralSet.id}
/>
);
}
return <ApplyCodeFormContainer onSuccess={refetch} />;
};
export const useStats = ({
data,
program,
}: {
data?: NonNullable<ReturnType<typeof useReferral>['data']>;
program: ReturnType<typeof useReferralProgram>;
}) => {
const { benefitTiers } = program;
const { data: epochData } = useEpochInfoQuery({
fetchPolicy: 'network-only',
});
const { data: statsData } = useReferralSetStatsQuery({
variables: {
code: data?.code || '',
},
skip: !data?.code,
fetchPolicy: 'cache-and-network',
});
const currentEpoch = Number(epochData?.epoch.id);
const stats =
statsData?.referralSetStats.edges &&
compact(removePaginationWrapper(statsData.referralSetStats.edges));
const refereeInfo = data?.referee;
const refereeStats = stats?.find(
(r) => r.partyId === data?.referee?.refereeId
);
const statsAvailable = stats && stats.length > 0 && stats[0];
const baseCommissionValue = statsAvailable
? Number(statsAvailable.rewardFactor)
: 0;
const runningVolumeValue = statsAvailable
? Number(statsAvailable.referralSetRunningNotionalTakerVolume)
: 0;
const referrerVolumeValue = statsAvailable
? Number(statsAvailable.referrerTakerVolume)
: 0;
const multiplier = statsAvailable
? Number(statsAvailable.rewardsMultiplier)
: 1;
const finalCommissionValue = isNaN(multiplier)
? baseCommissionValue
: new BigNumber(multiplier).times(baseCommissionValue).toNumber();
const discountFactorValue = refereeStats?.discountFactor
? Number(refereeStats.discountFactor)
: 0;
const currentBenefitTierValue = benefitTiers.find(
(t) =>
!isNaN(discountFactorValue) &&
!isNaN(t.discountFactor) &&
t.discountFactor === discountFactorValue
);
const nextBenefitTierValue = currentBenefitTierValue
? benefitTiers.find((t) => t.tier === currentBenefitTierValue.tier + 1)
: minBy(benefitTiers, (bt) => bt.tier); // min tier number is lowest tier
const epochsValue =
!isNaN(currentEpoch) && refereeInfo?.atEpoch
? currentEpoch - refereeInfo?.atEpoch
: 0;
const nextBenefitTierVolumeValue = nextBenefitTierValue
? nextBenefitTierValue.minimumVolume - runningVolumeValue
: 0;
const nextBenefitTierEpochsValue = nextBenefitTierValue
? nextBenefitTierValue.epochs - epochsValue
: 0;
return {
baseCommissionValue,
runningVolumeValue,
referrerVolumeValue,
multiplier,
finalCommissionValue,
discountFactorValue,
currentBenefitTierValue,
nextBenefitTierValue,
epochsValue,
nextBenefitTierVolumeValue,
nextBenefitTierEpochsValue,
};
};
export const Statistics = ({
data,
program,
as,
}: {
data: NonNullable<ReturnType<typeof useReferral>['data']>;
program: ReturnType<typeof useReferralProgram>;
as: 'referrer' | 'referee';
}) => {
const t = useT();
const {
baseCommissionValue,
runningVolumeValue,
referrerVolumeValue,
multiplier,
finalCommissionValue,
discountFactorValue,
currentBenefitTierValue,
epochsValue,
nextBenefitTierValue,
nextBenefitTierVolumeValue,
nextBenefitTierEpochsValue,
} = useStats({ data, program });
const isApplyCodePreview = data.referee === null;
const { benefitTiers } = useReferralProgram();
const { stakeAvailable, isEligible } = useStakeAvailable();
const { details } = program;
const compactNumFormat = new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
});
const baseCommissionTile = (
<StatTile
title={t('Base commission rate')}
description={t(
'(Combined set volume {{runningVolume}} over last {{epochs}} epochs)',
{
runningVolume: compactNumFormat.format(runningVolumeValue),
epochs: (
details?.windowLength || DEFAULT_AGGREGATION_DAYS
).toString(),
}
)}
testId="base-commission-rate"
overrideWithNoProgram={!details}
>
{baseCommissionValue * 100}%
</StatTile>
);
const stakingMultiplierTile = (
<StatTile
title={t('Staking multiplier')}
testId="staking-multiplier"
description={
<span
className={classNames({
'text-vega-red': !isEligible,
})}
>
{t('{{amount}} $VEGA staked', {
amount: addDecimalsFormatNumber(
stakeAvailable?.toString() || 0,
18
),
})}
</span>
}
overrideWithNoProgram={!details}
>
{multiplier || t('None')}
</StatTile>
);
const baseCommissionFormatted = BigNumber(baseCommissionValue)
.times(100)
.toString();
const finalCommissionFormatted = new BigNumber(finalCommissionValue)
.times(100)
.toString();
const finalCommissionTile = (
<StatTile
title={t('Final commission rate')}
description={
!isNaN(multiplier)
? `(${baseCommissionFormatted}% ⨉ ${multiplier} = ${finalCommissionFormatted}%)`
: undefined
}
testId="final-commission-rate"
overrideWithNoProgram={!details}
>
{finalCommissionFormatted}%
</StatTile>
);
const numberOfTradersValue = data.referees.length;
const numberOfTradersTile = (
<StatTile title={t('Number of traders')} testId="number-of-traders">
{numberOfTradersValue}
</StatTile>
);
const codeTile = (
<CodeTile
code={data?.code}
createdAt={getDateFormat().format(new Date(data.createdAt))}
/>
);
const referrerVolumeTile = (
<StatTile
title={t('myVolume', 'My volume (last {{count}} epochs)', {
count: details?.windowLength || DEFAULT_AGGREGATION_DAYS,
})}
testId="my-volume"
overrideWithNoProgram={!details}
>
{compactNumFormat.format(referrerVolumeValue)}
</StatTile>
);
const totalCommissionValue = data.referees
.map((r) => new BigNumber(r.totalRefereeGeneratedRewards))
.reduce((all, r) => all.plus(r), new BigNumber(0));
const totalCommissionTile = (
<StatTile
testId="total-commission"
title={
<Trans
i18nKey="totalCommission"
defaults="Total commission (<0>last {{count}} epochs</0>)"
values={{
count: DEFAULT_AGGREGATION_DAYS,
}}
components={[
<Tooltip
key="1"
description={t(
'Depending on data node retention you may not be able see the full 30 days'
)}
>
last 30 epochs
</Tooltip>,
]}
/>
}
description={<QUSDTooltip />}
>
{formatNumber(totalCommissionValue, 0)}
</StatTile>
);
const currentBenefitTierTile = (
<StatTile
title={t('Current tier')}
testId="current-tier"
description={
nextBenefitTierValue?.tier
? t('(Next tier: {{nextTier}})', {
nextTier: nextBenefitTierValue?.tier,
})
: undefined
}
overrideWithNoProgram={!details}
>
{isApplyCodePreview
? currentBenefitTierValue?.tier || benefitTiers[0]?.tier || 'None'
: currentBenefitTierValue?.tier || 'None'}
</StatTile>
);
const discountFactorTile = (
<StatTile
title={t('Discount')}
testId="discount"
overrideWithNoProgram={!details}
>
{isApplyCodePreview && benefitTiers.length >= 1
? benefitTiers[0].discountFactor * 100
: discountFactorValue * 100}
%
</StatTile>
);
const runningVolumeTile = (
<StatTile
title={t(
'runningNotionalOverEpochs',
'Combined volume (last {{count}} epochs)',
{
count: details?.windowLength,
}
)}
testId="combined-volume"
overrideWithNoProgram={!details}
>
{compactNumFormat.format(runningVolumeValue)}
</StatTile>
);
const epochsTile = (
<StatTile title={t('Epochs in set')} testId="epochs-in-set">
{epochsValue}
</StatTile>
);
const nextTierVolumeTile = (
<StatTile
title={t('Volume to next tier')}
testId="vol-to-next-tier"
overrideWithNoProgram={!details}
>
{nextBenefitTierVolumeValue <= 0
? '0'
: compactNumFormat.format(nextBenefitTierVolumeValue)}
</StatTile>
);
const nextTierEpochsTile = (
<StatTile
title={t('Epochs to next tier')}
testId="epochs-to-next-tier"
overrideWithNoProgram={!details}
>
{nextBenefitTierEpochsValue <= 0 ? '0' : nextBenefitTierEpochsValue}
</StatTile>
);
const eligibilityWarningOverlay = as === 'referee' && !isEligible && (
<div
data-testid="referral-eligibility-warning"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-1/2 lg:w-1/3"
>
<h2 className="text-2xl mb-2">{t('Referral code no longer valid')}</h2>
<p>
{t(
'Your referral code is no longer valid as the referrer no longer meets the minimum requirements. Apply a new code to continue receiving discounts.'
)}
</p>
</div>
);
const referrerTiles = (
<>
<Team teamId={data.code} />
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
{baseCommissionTile}
{stakingMultiplierTile}
{finalCommissionTile}
</div>
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{codeTile}
{referrerVolumeTile}
{numberOfTradersTile}
{totalCommissionTile}
</div>
</>
);
const refereeTiles = (
<>
<Team teamId={data.code} />
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
{currentBenefitTierTile}
{runningVolumeTile}
{codeTile}
</div>
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{discountFactorTile}
{nextTierVolumeTile}
{epochsTile}
{nextTierEpochsTile}
</div>
</>
);
return (
<div
data-testid="referral-statistics"
data-as={as}
className="relative mx-auto mb-20"
>
<div
className={classNames('grid grid-cols-1 grid-rows-1 gap-5', {
'opacity-20 pointer-events-none': as === 'referee' && !isEligible,
})}
>
{as === 'referrer' && referrerTiles}
{as === 'referee' && refereeTiles}
</div>
{eligibilityWarningOverlay}
</div>
);
};
export const RefereesTable = ({
data,
program,
}: {
data: NonNullable<ReturnType<typeof useReferral>['data']>;
program: ReturnType<typeof useReferralProgram>;
}) => {
const t = useT();
const [collapsed, setCollapsed] = useState(false);
const tableRef = useRef<HTMLTableElement>(null);
const { details } = program;
useLayoutEffect(() => {
if ((tableRef.current?.getBoundingClientRect().height || 0) > 384) {
setCollapsed(true);
}
}, []);
return (
<>
{/* Referees (only for referrer view) */}
{data.referees.length > 0 && (
<div className="mt-20 mb-20">
<h2 className="mb-5 text-2xl">{t('Referees')}</h2>
<div
className={classNames(
collapsed && [
'relative max-h-96 overflow-hidden',
'after:w-full after:h-20 after:absolute after:bottom-0 after:left-0',
'after:bg-gradient-to-t after:from-white after:dark:from-vega-cdark-900 after:to-transparent',
]
)}
>
<button
className={classNames(
'absolute left-1/2 bottom-0 z-10 p-2 translate-x-[-50%]',
{
hidden: !collapsed,
}
)}
onClick={() => setCollapsed(false)}
>
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} size={24} />
</button>
<Table
ref={tableRef}
columns={[
{ name: 'party', displayName: t('Trader') },
{ name: 'joined', displayName: t('Date Joined') },
{
name: 'volume',
displayName: t(
'volumeLastEpochs',
'Volume (last {{count}} epochs)',
{
count: details?.windowLength || DEFAULT_AGGREGATION_DAYS,
}
),
},
{
name: 'commission',
displayName: (
<Trans
i18nKey="referralStatisticsCommission"
defaults="Commission earned in <0>qUSD</0> (<1>last {{count}} epochs</1>)"
components={[
<QUSDTooltip key="0" />,
<Tooltip
key="1"
description={t(
'Depending on data node retention you may not be able see the full 30 days'
)}
>
last 30 epochs
</Tooltip>,
]}
values={{
count: DEFAULT_AGGREGATION_DAYS,
}}
ns={ns}
/>
),
},
]}
data={sortBy(
data.referees.map((r) => ({
party: (
<span title={r.refereeId}>
{truncateMiddle(r.refereeId)}
</span>
),
joined: getDateTimeFormat().format(new Date(r.joinedAt)),
volume: Number(r.totalRefereeNotionalTakerVolume),
commission: Number(r.totalRefereeGeneratedRewards),
})),
(r) => r.volume
)
.map((r) => ({
...r,
volume: formatNumber(r.volume, 0),
commission: formatNumber(r.commission, 0),
}))
.reverse()}
/>
</div>
</div>
)}
</>
);
};
const Team = ({ teamId }: { teamId?: string }) => {
const { team, members } = useTeam(teamId);
const { data: games } = useGames(teamId);
if (!team) return null;
return (
<Tile className="flex gap-3 lg:gap-4">
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<div className="flex flex-col items-start gap-1 lg:gap-3">
<h1 className="calt text-2xl lg:text-3xl xl:text-5xl">{team.name}</h1>
<TeamStats
members={members}
games={areTeamGames(games) ? games : undefined}
/>
</div>
</Tile>
);
};

View File

@ -10,12 +10,12 @@ import { TabLink } from './buttons';
import { Outlet, useMatch } from 'react-router-dom';
import { Routes } from '../../lib/links';
import { useVegaWallet } from '@vegaprotocol/wallet-react';
import { useReferral } from './hooks/use-referral';
import { REFERRAL_DOCS_LINK } from './constants';
import classNames from 'classnames';
import { useT } from '../../lib/use-t';
import { ErrorBoundary } from '../../components/error-boundary';
import { usePageTitle } from '../../lib/hooks/use-page-title';
import { useFindReferralSet } from './hooks/use-find-referral-set';
const Nav = () => {
const t = useT();
@ -34,26 +34,9 @@ export const Referrals = () => {
const t = useT();
const { pubKey } = useVegaWallet();
const {
data: referee,
loading: refereeLoading,
error: refereeError,
} = useReferral({
pubKey,
role: 'referee',
});
const {
data: referrer,
loading: referrerLoading,
error: referrerError,
} = useReferral({
pubKey,
role: 'referrer',
});
const { data, loading, error } = useFindReferralSet(pubKey);
const error = refereeError || referrerError;
const loading = refereeLoading || referrerLoading;
const showNav = !loading && !error && !referrer && !referee;
const showNav = !loading && !error && !data;
usePageTitle(t('Referrals'));

View File

@ -0,0 +1,73 @@
import classNames from 'classnames';
import { useReferrerStats } from './hooks/use-referrer-stats';
import {
BaseCommissionTile,
FinalCommissionTile,
RefereesTile,
StakingMultiplierTile,
TeamTile,
TotalCommissionTile,
VolumeTile,
dateFormatter,
} from './tiles';
import { CodeTile } from './tile';
export const ReferrerStatistics = ({
aggregationEpochs,
setId,
createdAt,
}: {
/** The aggregation epochs used to calculate statistics. */
aggregationEpochs: number;
/** The set id (code). */
setId: string;
/** The referral set date of creation. */
createdAt: string;
}) => {
const {
baseCommission,
finalCommission,
multiplier,
referees,
runningVolume,
totalCommission,
volume,
} = useReferrerStats(setId, aggregationEpochs);
return (
<div
data-testid="referral-statistics"
data-as="referrer"
className="relative mx-auto mb-20"
>
<div className={classNames('grid grid-cols-1 grid-rows-1 gap-5')}>
{/** TEAM TILE - referral set id is the same as team id */}
<TeamTile teamId={setId} />
{/** TILES ROW 1 */}
<div className="grid grid-rows-1 gap-5 grid-cols-1 md:grid-cols-3">
<BaseCommissionTile
aggregationEpochs={aggregationEpochs}
baseCommission={baseCommission}
runningVolume={runningVolume}
/>
<StakingMultiplierTile multiplier={multiplier} />
<FinalCommissionTile
baseCommission={baseCommission}
multiplier={multiplier}
finalCommission={finalCommission}
/>
</div>
{/** TILES ROW 2 */}
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
<CodeTile code={setId} createdAt={dateFormatter(createdAt)} />
<VolumeTile aggregationEpochs={aggregationEpochs} volume={volume} />
<RefereesTile referees={referees} />
<TotalCommissionTile
aggregationEpochs={aggregationEpochs}
totalCommission={totalCommission}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,440 @@
import { addDecimalsFormatNumber, getDateFormat } from '@vegaprotocol/utils';
import { useStakeAvailable } from '../../lib/hooks/use-stake-available';
import { useT } from '../../lib/use-t';
import BigNumber from 'bignumber.js';
import classNames from 'classnames';
import { type ReactNode } from 'react';
import { Trans } from 'react-i18next';
import { type StatValue, COMPACT_NUMBER_FORMAT } from './constants';
import { type ReferrerStats } from './hooks/use-referrer-stats';
import { type RefereeStats } from './hooks/use-referee-stats';
import { QUSDTooltip } from './qusd-tooltip';
import { NoProgramTile, StatTile, Tile } from './tile';
import { Loader, Tooltip } from '@vegaprotocol/ui-toolkit';
import { type BenefitTier } from './hooks/use-referral-program';
import { useTeam } from '../../lib/hooks/use-team';
import { areTeamGames, useGames } from '../../lib/hooks/use-games';
import { TeamAvatar } from '../../components/competitions/team-avatar';
import { TeamStats } from '../../components/competitions/team-stats';
/* Formatters */
const percentageFormatter = (value: BigNumber) =>
value.times(100).toFixed(2) + '%';
const compactFormatter =
(maximumFractionDigits = 2) =>
(value: BigNumber) =>
COMPACT_NUMBER_FORMAT(maximumFractionDigits).format(value.toNumber());
const valueFormatter = (noValueLabel: string) => (value: BigNumber) => {
if (value.isNaN() || value.isZero()) {
return noValueLabel;
}
return value.toString();
};
export const dateFormatter = (value: string) => {
try {
return getDateFormat().format(new Date(value));
} catch {
return '-';
}
};
/* Helpers */
const Value = <T,>({
data: { value, loading, error },
formatter,
}: {
data: StatValue<T>;
formatter: (value: T) => ReactNode;
}) => {
if (loading) {
return (
<span className="p-[33px]">
<Loader size="small" />
</span>
);
}
if (error) {
return <span data-error={error.message}>-</span>;
}
return formatter(value);
};
/* Referrer tiles */
export const BaseCommissionTile = ({
baseCommission,
runningVolume,
aggregationEpochs,
}: {
baseCommission: ReferrerStats['baseCommission'];
runningVolume: ReferrerStats['runningVolume'];
aggregationEpochs: number;
}) => {
const t = useT();
const runningVolumeDescription = compactFormatter(2)(runningVolume.value);
const description = t(
'(Combined set volume {{runningVolume}} over last {{epochs}} epochs)',
{
runningVolume: runningVolumeDescription,
epochs: aggregationEpochs.toString(),
}
);
return (
<StatTile
title={t('Base commission rate')}
description={description}
testId="base-commission-rate"
>
<Value data={baseCommission} formatter={percentageFormatter} />
</StatTile>
);
};
export const StakingMultiplierTile = ({
multiplier,
}: {
multiplier: ReferrerStats['multiplier'];
}) => {
const t = useT();
const { stakeAvailable, isEligible } = useStakeAvailable();
const description = (
<span
className={classNames({
'text-vega-red': !isEligible,
})}
>
{t('{{amount}} $VEGA staked', {
amount: addDecimalsFormatNumber(stakeAvailable?.toString() || 0, 18),
})}
</span>
);
return (
<StatTile
title={t('Staking multiplier')}
description={description}
testId="staking-multiplier"
>
<Value data={multiplier} formatter={valueFormatter(t('None'))} />
</StatTile>
);
};
export const FinalCommissionTile = ({
baseCommission,
multiplier,
finalCommission,
}: {
baseCommission: ReferrerStats['baseCommission'];
multiplier: ReferrerStats['multiplier'];
finalCommission: ReferrerStats['finalCommission'];
}) => {
const t = useT();
const description =
!baseCommission.loading && !finalCommission.loading && !multiplier.loading
? `(${percentageFormatter(
baseCommission.value
)} &times; ${multiplier.value.toString()} = ${percentageFormatter(
finalCommission.value
)})`
: undefined;
return (
<StatTile
title={t('Final commission rate')}
description={description}
testId="final-commission-rate"
>
<Value data={finalCommission} formatter={percentageFormatter} />
</StatTile>
);
};
export const VolumeTile = ({
volume,
aggregationEpochs,
}: {
volume: ReferrerStats['volume'];
aggregationEpochs: number;
}) => {
const t = useT();
return (
<StatTile
title={t('myVolume', 'My volume (last {{count}} epochs)', {
count: aggregationEpochs,
})}
testId="my-volume"
>
<Value data={volume} formatter={compactFormatter(2)} />
</StatTile>
);
};
export const TotalCommissionTile = ({
totalCommission,
aggregationEpochs,
}: {
totalCommission: ReferrerStats['totalCommission'];
aggregationEpochs: number;
}) => {
const t = useT();
return (
<StatTile
testId="total-commission"
title={
<Trans
i18nKey="totalCommission"
defaults="Total commission (<0>last {{count}} epochs</0>)"
values={{
count: aggregationEpochs,
}}
components={[
<Tooltip
key="0"
description={t(
'Depending on data node retention you may not be able see the full 30 days'
)}
>
<span>last 30 epochs</span>
</Tooltip>,
]}
/>
}
description={<QUSDTooltip />}
>
<Value data={totalCommission} formatter={compactFormatter(0)} />
</StatTile>
);
};
export const RefereesTile = ({
referees,
}: {
referees: ReferrerStats['referees'];
}) => {
const t = useT();
return (
<StatTile title={t('Number of traders')} testId="number-of-traders">
<Value data={referees} formatter={valueFormatter(t('None'))} />
</StatTile>
);
};
/* Referee tiles */
export const BenefitTierTile = ({
benefitTier,
nextBenefitTier,
}: {
benefitTier: RefereeStats['benefitTier'];
nextBenefitTier: RefereeStats['nextBenefitTier'];
}) => {
const t = useT();
const formatter = (value: BenefitTier | undefined) =>
value?.tier || t('None');
const next = nextBenefitTier.value?.tier;
return (
<StatTile
title={t('Current tier')}
testId="current-tier"
description={
next
? t('(Next tier: {{nextTier}})', {
nextTier: next,
})
: undefined
}
>
<Value<BenefitTier | undefined>
data={benefitTier}
formatter={formatter}
/>
</StatTile>
);
};
export const RunningVolumeTile = ({
runningVolume,
aggregationEpochs,
}: {
runningVolume: RefereeStats['runningVolume'];
aggregationEpochs: number;
}) => {
const t = useT();
return (
<StatTile
title={t(
'runningNotionalOverEpochs',
'Combined volume (last {{count}} epochs)',
{
count: aggregationEpochs,
}
)}
testId="combined-volume"
>
<Value data={runningVolume} formatter={compactFormatter(2)} />
</StatTile>
);
};
export const DiscountTile = ({
discountFactor,
}: {
discountFactor: RefereeStats['discountFactor'];
}) => {
const t = useT();
return (
<StatTile title={t('Discount')} testId="discount">
<Value data={discountFactor} formatter={percentageFormatter} />
</StatTile>
);
};
export const NextTierVolumeTile = ({
runningVolume,
nextBenefitTier,
}: {
runningVolume: RefereeStats['runningVolume'];
nextBenefitTier: RefereeStats['nextBenefitTier'];
}) => {
const t = useT();
const data = {
loading: runningVolume.loading || nextBenefitTier.loading,
error: runningVolume.error || nextBenefitTier.error,
value: [runningVolume.value, nextBenefitTier.value] as [
BigNumber,
BenefitTier | undefined
],
};
const formatter = ([runningVolume, nextBenefitTier]: [
BigNumber,
BenefitTier | undefined
]) => {
if (!nextBenefitTier) return '0';
const volume = BigNumber(nextBenefitTier.minimumVolume).minus(
runningVolume
);
if (volume.isNaN() || volume.isLessThan(0)) return '0';
return compactFormatter(0)(volume);
};
return (
<StatTile title={t('Volume to next tier')} testId="vol-to-next-tier">
<Value<[BigNumber, BenefitTier | undefined]>
data={data}
formatter={formatter}
/>
</StatTile>
);
};
export const EpochsTile = ({ epochs }: { epochs: RefereeStats['epochs'] }) => {
const t = useT();
return (
<StatTile title={t('Epochs in set')} testId="epochs-in-set">
<Value data={epochs} formatter={valueFormatter(t('None'))} />
</StatTile>
);
};
export const NextTierEpochsTile = ({
epochs,
nextBenefitTier,
}: {
epochs: RefereeStats['epochs'];
nextBenefitTier: RefereeStats['nextBenefitTier'];
}) => {
const t = useT();
const data = {
value: [epochs.value, nextBenefitTier.value] as [
BigNumber,
BenefitTier | undefined
],
loading: epochs.loading || nextBenefitTier.loading,
error: epochs.error || nextBenefitTier.error,
};
const formatter = ([epochs, nextBenefitTier]: [
BigNumber,
BenefitTier | undefined
]) => {
if (!nextBenefitTier) return '-';
const value = BigNumber(nextBenefitTier.epochs).minus(epochs);
if (value.isLessThan(0)) {
return '0';
}
return value.toString(10);
};
return (
<StatTile title={t('Epochs to next tier')} testId="epochs-to-next-tier">
<Value data={data} formatter={formatter} />
</StatTile>
);
};
/* Additional settings */
/**
* A list for tiles that should be replaced with `NoProgramTile`
* when the referral program is not set.
*/
const NO_PROGRAM_TILES = {
[BaseCommissionTile.name]: 'Base commission rate',
[StakingMultiplierTile.name]: 'Staking multiplier',
[FinalCommissionTile.name]: 'Final commission rate',
[VolumeTile.name]: 'My volume',
[BenefitTierTile.name]: 'Current tier',
[DiscountTile.name]: 'Discount',
[RunningVolumeTile.name]: 'Combined volume',
[NextTierEpochsTile.name]: 'Epochs to next tier',
[NextTierVolumeTile.name]: 'Volume to next tier',
};
export const NoProgramTileFor = ({ tile }: { tile: string }) => {
const t = useT();
if (Object.keys(NO_PROGRAM_TILES).includes(tile)) {
return <NoProgramTile title={t(NO_PROGRAM_TILES[tile])} />;
}
return null;
};
/** Teams */
export const TeamTile = ({ teamId }: { teamId?: string }) => {
const { team, members } = useTeam(teamId);
const { data: games } = useGames(teamId);
if (!team) return null;
return (
<Tile className="flex gap-3 lg:gap-4">
<TeamAvatar teamId={team.teamId} imgUrl={team.avatarUrl} />
<div className="flex flex-col items-start gap-1 lg:gap-3">
<h1 className="calt text-2xl lg:text-3xl xl:text-5xl">{team.name}</h1>
<TeamStats
members={members}
games={areTeamGames(games) ? games : undefined}
/>
</div>
</Tile>
);
};