Badges for streak (#2971)
@@ -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 |
@@ -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 :
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
| "globe"
|
||||
| "heart_filled"
|
||||
| "heart_outline"
|
||||
| "hexagon"
|
||||
| "hourglass_slim"
|
||||
| "hourglass"
|
||||
| "info_filled"
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -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 }
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 89 KiB |
|
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;
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||