diff --git a/icons/hexagon.svg b/icons/hexagon.svg
new file mode 100644
index 0000000000..833a3a954f
--- /dev/null
+++ b/icons/hexagon.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/api/services/rewards.ts b/lib/api/services/rewards.ts
index ae26c2948d..10b2310fb3 100644
--- a/lib/api/services/rewards.ts
+++ b/lib/api/services/rewards.ts
@@ -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 :
diff --git a/mocks/rewards/streakBadges.ts b/mocks/rewards/streakBadges.ts
new file mode 100644
index 0000000000..7e69743e25
--- /dev/null
+++ b/mocks/rewards/streakBadges.ts
@@ -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,
+ },
+ ],
+};
diff --git a/package.json b/package.json
index bb6e894422..8441bbc5fa 100644
--- a/package.json
+++ b/package.json
@@ -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.9.0",
"@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6",
"@blockscout/visualizer-types": "0.2.0",
diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts
index 06b734a9d4..aeb2ea3dd2 100644
--- a/public/icons/name.d.ts
+++ b/public/icons/name.d.ts
@@ -83,6 +83,7 @@
| "globe"
| "heart_filled"
| "heart_outline"
+ | "hexagon"
| "hourglass_slim"
| "hourglass"
| "info_filled"
diff --git a/public/static/merits/streak_180.png b/public/static/merits/streak_180.png
new file mode 100644
index 0000000000..e785dee97e
Binary files /dev/null and b/public/static/merits/streak_180.png differ
diff --git a/public/static/merits/streak_180_ghost.png b/public/static/merits/streak_180_ghost.png
new file mode 100644
index 0000000000..25d1705b41
Binary files /dev/null and b/public/static/merits/streak_180_ghost.png differ
diff --git a/public/static/merits/streak_30.png b/public/static/merits/streak_30.png
new file mode 100644
index 0000000000..36a95a3dad
Binary files /dev/null and b/public/static/merits/streak_30.png differ
diff --git a/public/static/merits/streak_30_ghost.png b/public/static/merits/streak_30_ghost.png
new file mode 100644
index 0000000000..f9c3bcc916
Binary files /dev/null and b/public/static/merits/streak_30_ghost.png differ
diff --git a/public/static/merits/streak_90.png b/public/static/merits/streak_90.png
new file mode 100644
index 0000000000..83b80e4f40
Binary files /dev/null and b/public/static/merits/streak_90.png differ
diff --git a/public/static/merits/streak_90_ghost.png b/public/static/merits/streak_90_ghost.png
new file mode 100644
index 0000000000..2ab2e8b0f6
Binary files /dev/null and b/public/static/merits/streak_90_ghost.png differ
diff --git a/ui/pages/RewardsDashboard.tsx b/ui/pages/RewardsDashboard.tsx
index 816b20d774..e75fd40f67 100644
--- a/ui/pages/RewardsDashboard.tsx
+++ b/ui/pages/RewardsDashboard.tsx
@@ -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 (
<>
@@ -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={ }
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' } }}
>
{
- Current number of consecutive days you{ apos }ve claimed your daily Merits.{ ' ' }
- The longer your streak, the more daily Merits you can earn.{ ' ' }
-
- Share on X
-
- >
- ) }
+ 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={ (
+
+ ) }
>
@@ -152,6 +150,14 @@ const RewardsDashboard = () => {
] }
/>
+ { !isBadgesLoading && !dailyRewardQuery.isPending && (
+
+ ) }
>
);
};
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_activity-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_activity-tab-dark-mode-mobile-1.png
index 4362a9a293..3445672314 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_activity-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_activity-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_referrals-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_referrals-tab-dark-mode-mobile-1.png
index bfa4bf9834..0140b13c30 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_referrals-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_referrals-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_resources-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_resources-tab-dark-mode-mobile-1.png
index 1119942027..a0516f0777 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_resources-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_resources-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_activity-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_activity-tab-dark-mode-mobile-1.png
index f58f092a87..73a9c17aa4 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_activity-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_activity-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_referrals-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_referrals-tab-dark-mode-mobile-1.png
index d8f74cb396..40614c4a1b 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_referrals-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_referrals-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_resources-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_resources-tab-dark-mode-mobile-1.png
index 6f80b5b6ba..d2dd967fd4 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_resources-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_resources-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png
index 02c8a575a7..7260db4594 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_activity-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_activity-tab-dark-mode-mobile-1.png
index 72eb98a54b..48e78809af 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_activity-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_activity-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_referrals-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_referrals-tab-dark-mode-mobile-1.png
index 1e983a2d7c..ffb3dc0ec6 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_referrals-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_referrals-tab-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_resources-tab-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_resources-tab-dark-mode-mobile-1.png
index 107100b7fb..1271bfa9c0 100644
Binary files a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_resources-tab-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_resources-tab-dark-mode-mobile-1.png differ
diff --git a/ui/rewards/dashboard/DailyRewardClaimButton.tsx b/ui/rewards/dashboard/DailyRewardClaimButton.tsx
index f7aee397d4..1dd843a373 100644
--- a/ui/rewards/dashboard/DailyRewardClaimButton.tsx
+++ b/ui/rewards/dashboard/DailyRewardClaimButton.tsx
@@ -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('');
@@ -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) {
diff --git a/ui/rewards/dashboard/RewardsDashboardCard.tsx b/ui/rewards/dashboard/RewardsDashboardCard.tsx
index 2ea3ab17f6..f7e09fade0 100644
--- a/ui/rewards/dashboard/RewardsDashboardCard.tsx
+++ b/ui/rewards/dashboard/RewardsDashboardCard.tsx
@@ -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 && { label } }
{ title && (
@@ -56,7 +57,9 @@ const RewardsDashboardCard = ({
{ description }
- { contentAfter }
+
+ { contentAfter }
+
(
-
+const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading, bottomText, isBottomTextLoading }: Props) => (
+
{ label && (
{ hint && }
@@ -40,8 +41,8 @@ const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading, bottomT
{ bottomText && (
-
-
+
+
{ bottomText }
diff --git a/ui/rewards/dashboard/streakModal/BadgeCard.tsx b/ui/rewards/dashboard/streakModal/BadgeCard.tsx
new file mode 100644
index 0000000000..b0613c9a07
--- /dev/null
+++ b/ui/rewards/dashboard/streakModal/BadgeCard.tsx
@@ -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 (
+
+
+
+
+
+ { target } Day streak
+
+ { (() => {
+ if (badge.is_minted) {
+ return (
+
+ Minted
+
+ );
+ }
+ if (badge.is_whitelisted) {
+ return (
+
+ Mint a badge
+
+ );
+ }
+ return (
+ <>
+
+ { progress }/{ target }
+
+
+
+
+
+
+ >
+ );
+ })() }
+
+
+
+ );
+}
diff --git a/ui/rewards/dashboard/streakModal/ProgressSegment.tsx b/ui/rewards/dashboard/streakModal/ProgressSegment.tsx
new file mode 100644
index 0000000000..b699250d30
--- /dev/null
+++ b/ui/rewards/dashboard/streakModal/ProgressSegment.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+ { isDone ? (
+
+ ) : (
+
+ ) }
+
+
+ { target } Days
+
+
+ );
+}
diff --git a/ui/rewards/dashboard/streakModal/RewardsStreakModal.pw.tsx b/ui/rewards/dashboard/streakModal/RewardsStreakModal.pw.tsx
new file mode 100644
index 0000000000..cb4b11364f
--- /dev/null
+++ b/ui/rewards/dashboard/streakModal/RewardsStreakModal.pw.tsx
@@ -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(
+ ,
+ );
+ 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));
+});
diff --git a/ui/rewards/dashboard/streakModal/RewardsStreakModal.tsx b/ui/rewards/dashboard/streakModal/RewardsStreakModal.tsx
new file mode 100644
index 0000000000..007fb58618
--- /dev/null
+++ b/ui/rewards/dashboard/streakModal/RewardsStreakModal.tsx
@@ -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 (
+
+
+ Streak progress
+
+
+
+
+ Build your streak day by day and unlock exclusive badges as a reward for staying consistent.
+
+
+
+ { currentStreak }
+ Day streak
+
+
+ { 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 (
+
+ );
+ }) }
+
+
+
+
+
+ Rewards
+
+ { badges.map((badge, i) => (
+ <>
+
+ { i < badges.length - 1 && (
+
+ ) }
+ >
+ )) }
+
+
+
+
+
+
+ );
+};
+
+export default RewardsStreakModal;
diff --git a/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png
new file mode 100644
index 0000000000..07cd189659
Binary files /dev/null and b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ
diff --git a/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_dark-color-mode_filled-view-dark-mode-1.png b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_dark-color-mode_filled-view-dark-mode-1.png
new file mode 100644
index 0000000000..c8e8729ddf
Binary files /dev/null and b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_dark-color-mode_filled-view-dark-mode-1.png differ
diff --git a/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_base-view-dark-mode-1.png b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_base-view-dark-mode-1.png
new file mode 100644
index 0000000000..39e1b1876f
Binary files /dev/null and b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_base-view-dark-mode-1.png differ
diff --git a/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_filled-view-dark-mode-1.png b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_filled-view-dark-mode-1.png
new file mode 100644
index 0000000000..cc0ea4dc89
Binary files /dev/null and b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_filled-view-dark-mode-1.png differ
diff --git a/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_mobile-base-view-1.png b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_mobile-base-view-1.png
new file mode 100644
index 0000000000..ab11ccf494
Binary files /dev/null and b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_mobile-base-view-1.png differ
diff --git a/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_mobile-filled-view-1.png b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_mobile-filled-view-1.png
new file mode 100644
index 0000000000..a5fbb94cc0
Binary files /dev/null and b/ui/rewards/dashboard/streakModal/__screenshots__/RewardsStreakModal.pw.tsx_default_mobile-filled-view-1.png differ
diff --git a/ui/rewards/hooks/useStreakBadges.ts b/ui/rewards/hooks/useStreakBadges.ts
new file mode 100644
index 0000000000..3543ae5a16
--- /dev/null
+++ b/ui/rewards/hooks/useStreakBadges.ts
@@ -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,
+ };
+}
diff --git a/yarn.lock b/yarn.lock
index e95c0004d6..0edfc85b2e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1573,10 +1573,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/stats-types@^2.9.0":
version "2.9.0"