Badges for streak (#2971)

This commit is contained in:
Max Alekseenko
2025-09-22 14:00:33 +02:00
committed by GitHub
parent b1ab4c567a
commit a4fc170a85
37 changed files with 472 additions and 35 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5.408a1 1 0 0 1 1 0l5.928 3.422a1 1 0 0 1 .5.866v6.845a1 1 0 0 1-.5.867L7.5 15.83a1 1 0 0 1-1 0L.572 12.408a1 1 0 0 1-.5-.867V4.696a1 1 0 0 1 .5-.866L6.5.408Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

+4
View File
@@ -34,6 +34,9 @@ export const REWARDS_API_RESOURCES = {
user_referrals: {
path: '/api/v1/user/referrals',
},
user_badges: {
path: '/api/v1/user/badges',
},
user_check_activity_pass: {
path: '/api/v1/activity/check-pass',
filterFields: [ 'address' as const ],
@@ -74,6 +77,7 @@ R extends 'rewards:user_balances' ? rewards.GetUserBalancesResponse :
R extends 'rewards:user_daily_check' ? rewards.DailyRewardCheckResponse :
R extends 'rewards:user_daily_claim' ? rewards.DailyRewardClaimResponse :
R extends 'rewards:user_referrals' ? rewards.GetReferralDataResponse :
R extends 'rewards:user_badges' ? rewards.GetAvailableBadgesResponse :
R extends 'rewards:user_check_activity_pass' ? rewards.CheckActivityPassResponse :
R extends 'rewards:user_activity' ? rewards.GetActivityRewardsResponse :
R extends 'rewards:user_activity_track_tx' ? rewards.PreSubmitTransactionResponse :
+71
View File
@@ -0,0 +1,71 @@
import type { GetAvailableBadgesResponse } from '@blockscout/points-types';
export const base: GetAvailableBadgesResponse = {
items: [
{
chain_id: '17000',
address: '0xf34B6A7d0BAbb5eBEe5521Ce7e5393A10782AE96',
requirements: {
streak: '30',
},
is_qualified: false,
is_whitelisted: false,
is_minted: false,
},
{
chain_id: '17000',
address: '0xf41583090c674E55d9755b0afcbbf9ea2FA378e7',
requirements: {
streak: '90',
},
is_qualified: false,
is_whitelisted: false,
is_minted: false,
},
{
chain_id: '17000',
address: '0x6a4676480C9E36652F62d4A751eeEf562E06a383',
requirements: {
streak: '180',
},
is_qualified: false,
is_whitelisted: false,
is_minted: false,
},
],
};
export const filled: GetAvailableBadgesResponse = {
items: [
{
chain_id: '17000',
address: '0xf34B6A7d0BAbb5eBEe5521Ce7e5393A10782AE96',
requirements: {
streak: '30',
},
is_qualified: true,
is_whitelisted: true,
is_minted: true,
},
{
chain_id: '17000',
address: '0xf41583090c674E55d9755b0afcbbf9ea2FA378e7',
requirements: {
streak: '90',
},
is_qualified: true,
is_whitelisted: true,
is_minted: true,
},
{
chain_id: '17000',
address: '0x6a4676480C9E36652F62d4A751eeEf562E06a383',
requirements: {
streak: '180',
},
is_qualified: true,
is_whitelisted: true,
is_minted: false,
},
],
};
+1 -1
View File
@@ -44,7 +44,7 @@
"dependencies": {
"@blockscout/bens-types": "1.4.1",
"@blockscout/multichain-aggregator-types": "1.6.0-alpha.2",
"@blockscout/points-types": "1.3.0-alpha.2",
"@blockscout/points-types": "1.4.0-alpha.1",
"@blockscout/stats-types": "2.10.0-alpha",
"@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6",
"@blockscout/visualizer-types": "0.2.0",
+1
View File
@@ -85,6 +85,7 @@
| "globe"
| "heart_filled"
| "heart_outline"
| "hexagon"
| "hourglass_slim"
| "hourglass"
| "info_filled"
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+30 -24
View File
@@ -4,21 +4,27 @@ import React, { useEffect, useState } from 'react';
import config from 'configs/app';
import { useRewardsContext } from 'lib/contexts/rewards';
import { Alert } from 'toolkit/chakra/alert';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { apos } from 'toolkit/utils/htmlEntities';
import DailyRewardClaimButton from 'ui/rewards/dashboard/DailyRewardClaimButton';
import RewardsDashboardCard from 'ui/rewards/dashboard/RewardsDashboardCard';
import RewardsDashboardCardValue from 'ui/rewards/dashboard/RewardsDashboardCardValue';
import RewardsStreakModal from 'ui/rewards/dashboard/streakModal/RewardsStreakModal';
import ActivityTab from 'ui/rewards/dashboard/tabs/ActivityTab';
import ReferralsTab from 'ui/rewards/dashboard/tabs/ReferralsTab';
import ResourcesTab from 'ui/rewards/dashboard/tabs/ResourcesTab';
import useStreakBadges from 'ui/rewards/hooks/useStreakBadges';
import AdBanner from 'ui/shared/ad/AdBanner';
import PageTitle from 'ui/shared/Page/PageTitle';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
const RewardsDashboard = () => {
const { balancesQuery, apiToken, referralsQuery, rewardsConfigQuery, dailyRewardQuery, isInitialized } = useRewardsContext();
const { nextAchievementText, isLoading: isBadgesLoading, badgesQuery } = useStreakBadges();
const streakModal = useDisclosure();
const [ isError, setIsError ] = useState(false);
@@ -38,13 +44,6 @@ const RewardsDashboard = () => {
return null;
}
let shareText = `Claim your free @blockscout #Merits and start building your daily streak today! #Blockscout #Merits #IYKYK\n\nBoost your rewards instantly by using my referral code: ${ referralsQuery.data?.link }`; // eslint-disable-line max-len
if (dailyRewardQuery.data?.streak && Number(dailyRewardQuery.data.streak) > 0) {
const days = `day${ Number(dailyRewardQuery.data.streak) === 1 ? '' : 's' }`;
shareText = `I${ apos }ve claimed Merits ${ dailyRewardQuery.data.streak } ${ days } in a row!\n\n` + shareText;
}
return (
<>
<Flex gap={ 3 } justifyContent="space-between" mb={ 6 }>
@@ -69,7 +68,7 @@ const RewardsDashboard = () => {
title="All Merits"
description="Claim your daily Merits and any Merits received from referrals."
contentDirection="column-reverse"
cardValueStyle={{ minH: { base: '64px', md: '88px' } }}
cardValueStyle={{ minH: { base: '64px', md: '116px' } }}
contentAfter={ <DailyRewardClaimButton/> }
hint={ (
<>
@@ -90,7 +89,7 @@ const RewardsDashboard = () => {
title="Referrals"
description="Total number of users who have joined the program using your code or referral link."
contentDirection="column-reverse"
cardValueStyle={{ minH: { base: '64px', md: '88px' } }}
cardValueStyle={{ minH: { base: '64px', md: '116px' } }}
>
<RewardsDashboardCardValue
value={ referralsQuery.data?.referrals ?
@@ -102,15 +101,9 @@ const RewardsDashboard = () => {
</RewardsDashboardCard>
<RewardsDashboardCard
title="Streak"
description={ (
<>
Current number of consecutive days you{ apos }ve claimed your daily Merits.{ ' ' }
The longer your streak, the more daily Merits you can earn.{ ' ' }
<Link external href={ `https://x.com/intent/tweet?text=${ encodeURIComponent(shareText) }` } fontWeight="500">
Share on X
</Link>
</>
) }
description={
`Current number of consecutive days you${ apos }ve claimed your daily Merits. The longer your streak, the more daily Merits you can earn.`
}
hint={ (
<>
See the{ ' ' }
@@ -119,15 +112,20 @@ const RewardsDashboard = () => {
</>
) }
contentDirection="column-reverse"
cardValueStyle={{ minH: { base: '64px', md: '88px' } }}
cardValueStyle={{ minH: { base: '64px', md: '116px' } }}
contentAfter={ (
<Button mt={ 3 } onClick={ streakModal.onOpen } loading={ isBadgesLoading }>
Check achievements
</Button>
) }
>
<RewardsDashboardCardValue
value={
dailyRewardQuery.data?.streak ?
`${ dailyRewardQuery.data?.streak } day${ Number(dailyRewardQuery.data?.streak) === 1 ? '' : 's' }` :
'N/A'
}
value={ dailyRewardQuery.data?.streak ?
`${ dailyRewardQuery.data?.streak } day${ Number(dailyRewardQuery.data?.streak) === 1 ? '' : 's' }` :
'N/A' }
isLoading={ dailyRewardQuery.isPending }
bottomText={ nextAchievementText }
isBottomTextLoading={ isBadgesLoading }
/>
</RewardsDashboardCard>
</Flex>
@@ -152,6 +150,14 @@ const RewardsDashboard = () => {
] }
/>
</Flex>
{ !isBadgesLoading && !dailyRewardQuery.isPending && (
<RewardsStreakModal
open={ streakModal.open }
onOpenChange={ streakModal.onOpenChange }
currentStreak={ Number(dailyRewardQuery.data?.streak || 0) }
badges={ badgesQuery.data?.items }
/>
) }
</>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 164 KiB

@@ -1,4 +1,5 @@
import { Flex } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useRewardsContext } from 'lib/contexts/rewards';
@@ -7,6 +8,7 @@ import { SECOND } from 'toolkit/utils/consts';
import splitSecondsInPeriods from 'ui/blockCountdown/splitSecondsInPeriods';
const DailyRewardClaimButton = () => {
const queryClient = useQueryClient();
const { balancesQuery, dailyRewardQuery, claim } = useRewardsContext();
const [ isClaiming, setIsClaiming ] = React.useState(false);
const [ timeLeft, setTimeLeft ] = React.useState<string>('');
@@ -25,9 +27,10 @@ const DailyRewardClaimButton = () => {
balancesQuery.refetch(),
dailyRewardQuery.refetch(),
]);
queryClient.invalidateQueries({ queryKey: [ 'rewards:user_badges' ] });
} catch (error) {}
setIsClaiming(false);
}, [ claim, setIsClaiming, balancesQuery, dailyRewardQuery ]);
}, [ claim, setIsClaiming, balancesQuery, dailyRewardQuery, queryClient ]);
useEffect(() => {
if (!dailyRewardQuery.data?.reset_at) {
@@ -44,6 +44,7 @@ const RewardsDashboardCard = ({
pb={ contentDirection === 'column-reverse' ? { base: 1.5, md: 3 } : 0 }
pt={ contentDirection === 'column-reverse' ? 0 : { base: 1.5, md: 3 } }
w={{ base: 'full', md: contentDirection === 'row' ? '340px' : 'full' }}
flex={ 1 }
>
{ label && <Badge loading={ isLoading }>{ label }</Badge> }
{ title && (
@@ -56,7 +57,9 @@ const RewardsDashboardCard = ({
<Text as="div" fontSize="sm">
{ description }
</Text>
{ contentAfter }
<Flex flexDirection="column" mt={ contentDirection === 'column-reverse' ? 'auto' : 0 }>
{ contentAfter }
</Flex>
</Flex>
<Flex
alignItems="center"
@@ -14,10 +14,11 @@ type Props = {
hint?: string | React.ReactNode;
isLoading?: boolean;
bottomText?: string;
isBottomTextLoading?: boolean;
};
const RewardsDashboardCardValue = ({ label, value, withIcon, hint, isLoading, bottomText }: Props) => (
<Flex key={ label } flexDirection="column" alignItems="center" gap={ 2 }>
const RewardsDashboardCardValue = ({ label, value, withIcon, hint, isLoading, bottomText, isBottomTextLoading }: Props) => (
<Flex key={ label } flexDirection="column" alignItems="center" gap={{ base: 1, md: 2 }}>
{ label && (
<Flex alignItems="center" gap={ 1 }>
{ hint && <Hint label={ hint }/> }
@@ -40,8 +41,8 @@ const RewardsDashboardCardValue = ({ label, value, withIcon, hint, isLoading, bo
</Heading>
</Skeleton>
{ bottomText && (
<Skeleton loading={ isLoading }>
<Text textStyle="xs" fontWeight="500" color="text.secondary">
<Skeleton loading={ isBottomTextLoading || isLoading } minW="100px">
<Text textStyle={{ base: 'xs', md: 'sm' }} color="text.secondary">
{ bottomText }
</Text>
</Skeleton>
@@ -0,0 +1,96 @@
import { Flex, Text, Progress } from '@chakra-ui/react';
import type { GetAvailableBadgesResponse } from '@blockscout/points-types';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
const BADGE_BG_COLORS = [ '#DFE8F5', '#D2E5FE', '#EFE1FF' ];
const BADGES = [
'/static/merits/streak_30.png',
'/static/merits/streak_90.png',
'/static/merits/streak_180.png',
] as const;
const GHOST_BADGES = [
'/static/merits/streak_30_ghost.png',
'/static/merits/streak_90_ghost.png',
'/static/merits/streak_180_ghost.png',
] as const;
type Props = {
badge: GetAvailableBadgesResponse['items'][number];
currentStreak: number;
index: number;
};
export default function BadgeCard({ badge, currentStreak, index }: Props) {
const target = Number(badge.requirements?.streak || 0);
const isUnlocked = badge.is_whitelisted || badge.is_minted;
const progress = Math.min(currentStreak, target);
return (
<Flex direction={{ base: 'row', lg: 'column' }} alignItems="center" gap={ 3 } flex={ 1 }>
<Flex
p={{ base: 2.5, lg: 4 }}
borderRadius="lg"
bgColor={ isUnlocked ? BADGE_BG_COLORS[index] : { _light: 'gray.50', _dark: 'whiteAlpha.100' } }
alignItems="center"
justifyContent="center"
w={{ base: '92px', lg: 'full' }}
flexShrink={ 0 }
>
<Image
src={ isUnlocked ? BADGES[index] : GHOST_BADGES[index] }
alt="Streak badge"
h={{ base: '54px', lg: '82px' }}
/>
</Flex>
<Flex direction="column" gap={ 3 } w="full" alignItems={{ base: 'flex-start', lg: 'center' }}>
<Text textStyle="sm">{ target } Day streak</Text>
<Flex
w="full"
alignItems="center"
justifyContent={{ base: 'flex-start', lg: 'center' }}
gap={ 2 }
px={{ base: 0, lg: 2 }}
h="32px"
>
{ (() => {
if (badge.is_minted) {
return (
<Text textStyle="xs" color="green.500">
Minted
</Text>
);
}
if (badge.is_whitelisted) {
return (
<Link
href={ `https://badges.blockscout.com/mint/${ badge.address }` }
external
textStyle="sm"
>
Mint a badge
</Link>
);
}
return (
<>
<Text textStyle="xs" color="text.secondary" minW="50px">
{ progress }/{ target }
</Text>
<Progress.Root value={ progress } min={ 0 } max={ target } flex={ 1 } size="xs" variant="subtle">
<Progress.Track h="4px" bg={{ _light: 'gray.200', _dark: 'whiteAlpha.200' }}>
<Progress.Range bg="green.400"/>
</Progress.Track>
</Progress.Root>
</>
);
})() }
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,66 @@
import { Flex, Text, Progress } from '@chakra-ui/react';
import { clamp } from 'es-toolkit';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
value: number;
target: number;
prevTarget: number;
isFirst: boolean;
};
export default function ProgressSegment({ value, target, prevTarget, isFirst }: Props) {
const isDone = value >= target;
const progress = clamp(value, prevTarget, target);
return (
<Flex gap={ 0 } flex={{ base: isFirst ? 0.7 : 1, lg: 1 }} minW="0">
<Flex flex={ 1 } alignItems="center" h={{ base: '32px', lg: '40px' }} mx={ -2.5 }>
<Progress.Root
value={ progress }
min={ prevTarget }
max={ target }
size="xs"
variant="subtle"
w="full"
>
<Progress.Track
h="4px"
bg={{ _light: 'gray.200', _dark: 'whiteAlpha.200' }}
borderStartRadius={ isFirst ? undefined : 0 }
borderEndRadius={ 0 }
>
<Progress.Range bg="green.400"/>
</Progress.Track>
</Progress.Root>
</Flex>
<Flex
direction="column"
alignItems="center"
gap={ 2 }
flexShrink={ 0 }
w="60px"
>
<Flex h={{ base: '32px', lg: '40px' }} alignItems="center">
<Flex
w="40px"
h="32px"
borderRadius="lg"
bgColor={ isDone ? 'green.400' : { _light: 'gray.200', _dark: 'whiteAlpha.200' } }
alignItems="center"
justifyContent="center"
zIndex={ 1 }
>
{ isDone ? (
<IconSvg name="check" boxSize={ 5 } color="white"/>
) : (
<IconSvg name="hexagon" boxSize={ 4 } color="icon.secondary"/>
) }
</Flex>
</Flex>
<Text textStyle="xs" color="text.secondary">{ target } Days</Text>
</Flex>
</Flex>
);
}
@@ -0,0 +1,33 @@
import React from 'react';
import type { GetAvailableBadgesResponse } from '@blockscout/points-types';
import * as streakBadgesMock from 'mocks/rewards/streakBadges';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect, devices } from 'playwright/lib';
import RewardsStreakModal from './RewardsStreakModal';
const onOpenChange = () => {};
const testFn = (streak: number, badges: GetAvailableBadgesResponse['items']) =>
async({ render, page }: TestFnArgs) => {
await render(
<RewardsStreakModal
open
onOpenChange={ onOpenChange }
currentStreak={ streak }
badges={ badges }
/>,
);
await expect(page).toHaveScreenshot();
};
test('base view +@dark-mode', testFn(10, streakBadgesMock.base.items));
test('filled view +@dark-mode', testFn(10, streakBadgesMock.filled.items));
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', testFn(10, streakBadgesMock.base.items));
test('filled view', testFn(10, streakBadgesMock.filled.items));
});
@@ -0,0 +1,94 @@
import { Flex, Text, Separator } from '@chakra-ui/react';
import React from 'react';
import type { GetAvailableBadgesResponse } from '@blockscout/points-types';
import { DialogBody, DialogContent, DialogHeader, DialogRoot } from 'toolkit/chakra/dialog';
import { Heading } from 'toolkit/chakra/heading';
import BadgeCard from './BadgeCard';
import ProgressSegment from './ProgressSegment';
type Props = {
open: boolean;
onOpenChange: ({ open }: { open: boolean }) => void;
currentStreak: number;
badges?: GetAvailableBadgesResponse['items'];
};
const EMPTY_ARRAY: GetAvailableBadgesResponse['items'] = [];
const RewardsStreakModal = ({ open, onOpenChange, currentStreak, badges = EMPTY_ARRAY }: Props) => {
return (
<DialogRoot open={ open } onOpenChange={ onOpenChange } size={{ lgDown: 'full', lg: 'md' }}>
<DialogContent>
<DialogHeader>Streak progress</DialogHeader>
<DialogBody>
<Flex direction="column" gap={ 6 }>
<Flex direction="column" gap={{ base: 6, lg: 3 }}>
<Text textStyle={{ base: 'sm', lg: 'md' }}>
Build your streak day by day and unlock exclusive badges as a reward for staying consistent.
</Text>
<Flex
direction={{ base: 'column', lg: 'row' }}
gap={{ base: 3, lg: 10 }}
bgColor={{ _light: 'gray.50', _dark: 'whiteAlpha.100' }}
p={{ base: 4, lg: 8 }}
pl={{ base: 4, lg: 5 }}
borderRadius="lg"
justifyContent="space-between"
>
<Flex direction="column" alignItems="center" gap={{ base: 1, lg: 2 }}>
<Heading level="1">{ currentStreak }</Heading>
<Text textStyle="xs" color="text.secondary">Day streak</Text>
</Flex>
<Flex flex={ 1 } pl={{ base: 2, lg: 0 }}>
{ badges.map((badge, i) => {
const target = Number(badge.requirements?.streak || 0);
const prevTarget = i > 0 ? Number(badges[i - 1]?.requirements?.streak || 0) : 0;
const value = (badge.is_whitelisted || badge.is_minted) ? target : currentStreak;
return (
<ProgressSegment
key={ i }
value={ value }
target={ target }
prevTarget={ prevTarget }
isFirst={ i === 0 }
/>
);
}) }
</Flex>
</Flex>
</Flex>
<Flex direction="column" gap={ 2 }>
<Heading level="3">Rewards</Heading>
<Flex direction={{ base: 'column', lg: 'row' }} gap={{ base: 2, lg: 6 }} justifyContent="space-between">
{ badges.map((badge, i) => (
<>
<BadgeCard
key={ i }
badge={ badge }
currentStreak={ currentStreak }
index={ i }
/>
{ i < badges.length - 1 && (
<Separator
display={{ base: 'none', lg: 'block' }}
orientation="vertical"
borderColor="border.divider"
/>
) }
</>
)) }
</Flex>
</Flex>
</Flex>
</DialogBody>
</DialogContent>
</DialogRoot>
);
};
export default RewardsStreakModal;
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import type { GetAvailableBadgesResponse } from '@blockscout/points-types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useRewardsContext } from 'lib/contexts/rewards';
const feature = config.features.rewards;
export default function useStreakBadges() {
const { apiToken, dailyRewardQuery } = useRewardsContext();
const badgesQuery = useApiQuery<'rewards:user_badges', unknown, GetAvailableBadgesResponse>('rewards:user_badges', {
queryOptions: {
enabled: feature.isEnabled && Boolean(apiToken),
select: (data) => ({
...data,
items: data.items
.sort((a, b) => Number(a.requirements?.streak || 0) - Number(b.requirements?.streak || 0))
.slice(0, 3), // UI limit
}),
},
fetchParams: { headers: { Authorization: `Bearer ${ apiToken }` } },
});
const nextAchievementText = useMemo(() => {
try {
if (badgesQuery.isPending || dailyRewardQuery.isPending) {
return 'Next achievement in N/A day'; // for skeleton
}
if (!badgesQuery.data?.items || !dailyRewardQuery.data?.streak) {
return undefined;
}
const currentStreak = Number(dailyRewardQuery.data.streak);
const next = badgesQuery.data.items.find((b) =>
!(b.is_qualified || b.is_whitelisted || b.is_minted) &&
Number(b.requirements?.streak) > currentStreak,
);
if (!next) {
return 'All achievements are earned';
}
const target = Number(next.requirements?.streak || 0);
const diff = target - currentStreak;
return `Next achievement in ${ diff } day${ diff === 1 ? '' : 's' }`;
} catch {
return undefined;
}
}, [ badgesQuery, dailyRewardQuery ]);
return {
badgesQuery,
nextAchievementText,
isLoading: badgesQuery.isPending,
};
}
+4 -4
View File
@@ -1580,10 +1580,10 @@
resolved "https://registry.yarnpkg.com/@blockscout/multichain-aggregator-types/-/multichain-aggregator-types-1.6.0-alpha.2.tgz#13f07652029a5d3bdcf4a07207c22599e3a5b08a"
integrity sha512-/013NEL+kC+vx3qOWWRTMhVFFifMjFXSRTjUkxnJPYrgdBZbYSNI1kB8vqe7z7nshu6uedkpgNMW2HpiVKUvQw==
"@blockscout/points-types@1.3.0-alpha.2":
version "1.3.0-alpha.2"
resolved "https://registry.yarnpkg.com/@blockscout/points-types/-/points-types-1.3.0-alpha.2.tgz#0308dcb4eef0dadf96f43b144835470e9f78f64f"
integrity sha512-tXCA51q3y08caCm7UhGyj+xsP0pd6yBhjElDHxEzM5SRop3culMiacaBXd0OPBszHjA0YdYgXFymuJhofB22ig==
"@blockscout/points-types@1.4.0-alpha.1":
version "1.4.0-alpha.1"
resolved "https://registry.yarnpkg.com/@blockscout/points-types/-/points-types-1.4.0-alpha.1.tgz#326885fcdda7c478440e4737efdfe105a29565fb"
integrity sha512-DaN42ulieCLjoKyDqVc5jCuho/A8g1k7OUpOMnVgc7k8D1esSlCg+LclJ7vrcigdUXsJ5cx2nlCkySKUOfr4mA==
"@blockscout/[email protected]":
version "2.10.0-alpha"