Skip to content

Commit 0756181

Browse files
lionellbrioneschaitanyapottilwin-kyaw
authored
feat: add shield rewards modal (#38379)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** - adds reward points details on shield plan - adds shield reward points modal <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/38379?quickstart=1) ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: adds shield rewards modal ## **Related issues** Fixes: ## **Manual testing steps** 1. Login to MetaMask with an account without shield subscription 2. Go to Menu > Settings : click on Transaction Shield 3. Shows Shield Entry modal: click on get started 4. Redirects to Shield plan form 5. Observe what you get list 6. Click on Rewards detail 7. Shows Rewards modal ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="372" height="454" alt="Screenshot 2025-12-04 at 12 12 22 AM" src="https://github.com/user-attachments/assets/846898e2-7d1c-4aef-9caf-86cf7a4ace9c" /> <img width="579" height="211" alt="Screenshot 2025-12-04 at 12 13 09 AM" src="https://github.com/user-attachments/assets/842b4c3d-e535-468b-8259-a697a1105698" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Integrates Rewards into Transaction Shield: fetches/ displays estimated points, adds Rewards badge and modal on Shield Plan, and shows rewards info in settings with opt-in. > > - **UI**: > - Shield Plan (`ui/pages/shield-plan/shield-plan.tsx`): shows Rewards badge with estimated points per selected interval; adds `ShieldRewardsModal`. > - Transaction Shield settings (`ui/pages/settings/transaction-shield-tab/transaction-shield.tsx`): displays rewards info row with estimated points and a Sign up button; loads Rewards onboarding modal; minor icon/color tweaks. > - **Hooks**: > - New `useShieldRewards` in `ui/hooks/subscription/useSubscription.ts` to estimate points (monthly/yearly), check rewards season, and account opt-in. > - **E2E Mocks**: > - Add Rewards API base URL and endpoints; mock responses and handlers (`test/e2e/helpers/shield/constants.ts`, `mocks.ts`). > - **Tests**: > - Initialize rewards duck state in `transaction-shield.test.tsx`. > - **i18n**: > - Add strings for rewards labels, descriptions, and CTA in `app/_locales/en*/messages.json`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 21f4e1c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Chaitanya Potti <[email protected]> Co-authored-by: Lwin <[email protected]>
1 parent 914ebff commit 0756181

File tree

10 files changed

+482
-10
lines changed

10 files changed

+482
-10
lines changed

app/_locales/en/messages.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/_locales/en_GB/messages.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/images/shield-reward.png

6.98 KB
Loading

test/e2e/helpers/shield/constants.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export const BASE_RULESET_ENGINE_API_URL =
66

77
export const BASE_CLAIMS_API_URL = 'https://claims.dev-api.cx.metamask.io';
88

9+
export const BASE_REWARDS_API_URL = 'https://rewards.uat-api.cx.metamask.io';
10+
911
export const SUBSCRIPTION_API = {
1012
PRICING: `${BASE_SUBSCRIPTION_API_URL}/pricing`,
1113
ELIGIBILITY: `${BASE_SUBSCRIPTION_API_URL}/subscriptions/eligibility`,
@@ -33,6 +35,37 @@ export const RULESET_ENGINE_API = {
3335
SIGNATURE_COVERAGE_RESULT: `${BASE_RULESET_ENGINE_API_URL}/signature/coverage/result`,
3436
};
3537

38+
export const REWARDS_API = {
39+
POINTS_ESTIMATION: `${BASE_REWARDS_API_URL}/points-estimation`,
40+
SEASONS_STATUS: `${BASE_REWARDS_API_URL}/public/seasons/status`,
41+
SEASON_METADATA: `${BASE_REWARDS_API_URL}/public/seasons`,
42+
};
43+
44+
// Mock response for rewards points estimation
45+
export const MOCK_REWARDS_POINTS_ESTIMATION_RESPONSE = {
46+
pointsEstimate: 100,
47+
bonusBips: 0,
48+
};
49+
50+
// Mock response for rewards seasons status - provides a valid current season
51+
export const MOCK_REWARDS_SEASONS_STATUS_RESPONSE = {
52+
current: {
53+
id: 'mock-season-1',
54+
startDate: '2025-01-01T00:00:00Z',
55+
endDate: '2025-12-31T23:59:59Z',
56+
},
57+
next: null,
58+
};
59+
60+
// Mock response for rewards season metadata
61+
export const MOCK_REWARDS_SEASON_METADATA_RESPONSE = {
62+
id: 'mock-season-1',
63+
name: 'Mock Season',
64+
startDate: '2025-01-01T00:00:00Z',
65+
endDate: '2025-12-31T23:59:59Z',
66+
tiers: [],
67+
};
68+
3669
export const BASE_SHIELD_SUBSCRIPTION_CARD = {
3770
id: 'test_subscription_id',
3871
status: 'trialing',

test/e2e/helpers/shield/mocks.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
MOCK_CLAIM_GENERATE_MESSAGE_RESPONSE,
2020
MOCK_CLAIM_1,
2121
RULESET_ENGINE_API,
22+
REWARDS_API,
23+
MOCK_REWARDS_POINTS_ESTIMATION_RESPONSE,
24+
MOCK_REWARDS_SEASONS_STATUS_RESPONSE,
25+
MOCK_REWARDS_SEASON_METADATA_RESPONSE,
2226
} from './constants';
2327

2428
export class ShieldMockttpService {
@@ -81,6 +85,9 @@ export class ShieldMockttpService {
8185

8286
// Ruleset Engine APIs
8387
await this.#handleRulesetEngine(server);
88+
89+
// Rewards APIs (needed for useShieldRewards hook on Shield Plan page)
90+
await this.#handleRewardsApis(server);
8491
}
8592

8693
async #handleSubscriptionPricing(server: Mockttp) {
@@ -449,4 +456,29 @@ export class ShieldMockttpService {
449456
};
450457
});
451458
}
459+
460+
async #handleRewardsApis(server: Mockttp) {
461+
// Mock points estimation endpoint (used by useShieldRewards hook)
462+
await server
463+
.forPost(REWARDS_API.POINTS_ESTIMATION)
464+
.always()
465+
.thenJson(200, MOCK_REWARDS_POINTS_ESTIMATION_RESPONSE);
466+
467+
// Mock seasons status endpoint (used by useShieldRewards hook)
468+
await server
469+
.forGet(REWARDS_API.SEASONS_STATUS)
470+
.always()
471+
.thenJson(200, MOCK_REWARDS_SEASONS_STATUS_RESPONSE);
472+
473+
// Mock season metadata endpoint (used by getRewardsSeasonMetadata)
474+
// This uses a regex to match /public/seasons/{seasonId}/meta
475+
const seasonMetadataRegex = new RegExp(
476+
`^${REWARDS_API.SEASON_METADATA}/[^/]+/meta$`,
477+
'u',
478+
);
479+
await server
480+
.forGet(seasonMetadataRegex)
481+
.always()
482+
.thenJson(200, MOCK_REWARDS_SEASON_METADATA_RESPONSE);
483+
}
452484
}

ui/hooks/subscription/useSubscription.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {
2323
addTransaction,
2424
cancelSubscription,
2525
estimateGas,
26+
estimateRewardsPoints,
27+
getRewardsHasAccountOptedIn,
28+
getRewardsSeasonMetadata,
2629
getSubscriptionBillingPortalUrl,
2730
getSubscriptions,
2831
getSubscriptionsEligibilities,
@@ -49,9 +52,11 @@ import { decimalToHex } from '../../../shared/modules/conversion.utils';
4952
import { CONFIRM_TRANSACTION_ROUTE } from '../../helpers/constants/routes';
5053
import { getInternalAccountBySelectedAccountGroupAndCaip } from '../../selectors/multichain-accounts/account-tree';
5154
import {
55+
getMetaMaskHdKeyrings,
5256
getMetaMetricsId,
5357
getModalTypeForShieldEntryModal,
5458
getUnapprovedConfirmations,
59+
getUpdatedAndSortedAccountsWithCaipAccountId,
5560
} from '../../selectors';
5661
import { useSubscriptionMetrics } from '../shield/metrics/useSubscriptionMetrics';
5762
import { CaptureShieldSubscriptionRequestParams } from '../shield/metrics/types';
@@ -69,6 +74,7 @@ import { MetaMetricsEventName } from '../../../shared/constants/metametrics';
6974
import { useAccountTotalFiatBalance } from '../useAccountTotalFiatBalance';
7075
import { getNetworkConfigurationsByChainId } from '../../../shared/modules/selectors/networks';
7176
import { isCryptoPaymentMethod } from '../../pages/settings/transaction-shield-tab/types';
77+
import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils';
7278
import {
7379
TokenWithApprovalAmount,
7480
useSubscriptionPricing,
@@ -735,3 +741,140 @@ export const useUpdateSubscriptionCryptoPaymentMethod = ({
735741
result,
736742
};
737743
};
744+
745+
export const useShieldRewards = (): {
746+
pending: boolean;
747+
pointsMonthly: number | null;
748+
pointsYearly: number | null;
749+
isRewardsSeason: boolean;
750+
hasAccountOptedIn: boolean;
751+
} => {
752+
const dispatch = useDispatch<MetaMaskReduxDispatch>();
753+
const [primaryKeyring] = useSelector(getMetaMaskHdKeyrings);
754+
const accountsWithCaipChainId = useSelector(
755+
getUpdatedAndSortedAccountsWithCaipAccountId,
756+
);
757+
758+
const caipAccountId = useMemo(() => {
759+
if (!primaryKeyring) {
760+
return null;
761+
}
762+
763+
const primaryAccountWithCaipChainId = accountsWithCaipChainId.find(
764+
(account) => {
765+
const entropySource = account.options?.entropySource;
766+
if (typeof entropySource === 'string') {
767+
return isEqualCaseInsensitive(
768+
entropySource,
769+
primaryKeyring.metadata.id,
770+
);
771+
}
772+
return false;
773+
},
774+
);
775+
if (!primaryAccountWithCaipChainId) {
776+
return null;
777+
}
778+
return primaryAccountWithCaipChainId.caipAccountId;
779+
}, [primaryKeyring, accountsWithCaipChainId]);
780+
781+
const {
782+
value: hasAccountOptedIn,
783+
pending: hasAccountOptedInResultPending,
784+
error: hasAccountOptedInResultError,
785+
} = useAsyncResult<boolean>(async () => {
786+
if (!caipAccountId) {
787+
return false;
788+
}
789+
return await dispatch(getRewardsHasAccountOptedIn(caipAccountId));
790+
}, [caipAccountId]);
791+
792+
const {
793+
value: pointsValue,
794+
pending: pointsPending,
795+
error: pointsError,
796+
} = useAsyncResult<{
797+
monthly: number | null;
798+
yearly: number | null;
799+
}>(async () => {
800+
if (!caipAccountId) {
801+
return { monthly: null, yearly: null };
802+
}
803+
804+
const [monthlyPointsData, yearlyPointsData] = await Promise.all([
805+
dispatch(
806+
estimateRewardsPoints({
807+
activityType: 'SHIELD',
808+
account: caipAccountId,
809+
activityContext: {
810+
shieldContext: {
811+
recurringInterval: 'month',
812+
},
813+
},
814+
}),
815+
),
816+
dispatch(
817+
estimateRewardsPoints({
818+
activityType: 'SHIELD',
819+
account: caipAccountId,
820+
activityContext: {
821+
shieldContext: {
822+
recurringInterval: 'year',
823+
},
824+
},
825+
}),
826+
),
827+
]);
828+
829+
return {
830+
monthly: monthlyPointsData?.pointsEstimate ?? null,
831+
yearly: yearlyPointsData?.pointsEstimate ?? null,
832+
};
833+
}, [dispatch, caipAccountId]);
834+
835+
const {
836+
value: isRewardsSeason,
837+
pending: seasonPending,
838+
error: seasonError,
839+
} = useAsyncResult<boolean>(async () => {
840+
const seasonMetadata = await dispatch(getRewardsSeasonMetadata('current'));
841+
842+
if (!seasonMetadata) {
843+
return false;
844+
}
845+
846+
const currentTimestamp = Date.now();
847+
return (
848+
currentTimestamp >= seasonMetadata.startDate &&
849+
currentTimestamp <= seasonMetadata.endDate
850+
);
851+
}, [dispatch]);
852+
853+
// if there is an error, return null values for points and season so it will not block the UI
854+
if (pointsError || seasonError || hasAccountOptedInResultError) {
855+
if (pointsError) {
856+
console.error('[useShieldRewards error]:', pointsError);
857+
}
858+
if (seasonError) {
859+
console.error('[useShieldRewards error]:', seasonError);
860+
}
861+
if (hasAccountOptedInResultError) {
862+
console.error('[useShieldRewards error]:', hasAccountOptedInResultError);
863+
}
864+
return {
865+
pending: false,
866+
pointsMonthly: null,
867+
pointsYearly: null,
868+
isRewardsSeason: false,
869+
hasAccountOptedIn: false,
870+
};
871+
}
872+
873+
return {
874+
pending: pointsPending || seasonPending || hasAccountOptedInResultPending,
875+
pointsMonthly: pointsValue?.monthly ?? null,
876+
pointsYearly: pointsValue?.yearly ?? null,
877+
isRewardsSeason: isRewardsSeason ?? false,
878+
hasAccountOptedIn: hasAccountOptedIn ?? false,
879+
};
880+
};

ui/pages/settings/transaction-shield-tab/transaction-shield.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@metamask/subscription-controller';
1212
import { renderWithProvider } from '../../../../test/jest/rendering';
1313
import mockState from '../../../../test/data/mock-state.json';
14+
import { initialState as rewardsInitialState } from '../../../ducks/rewards';
1415
import TransactionShield from './transaction-shield';
1516

1617
const mockUseNavigate = jest.fn();
@@ -38,6 +39,7 @@ jest.mock('./shield-subscription-icon-animation', () => ({
3839
describe('Transaction Shield Page', () => {
3940
const STATE_MOCK = {
4041
...mockState,
42+
rewards: rewardsInitialState,
4143
metamask: {
4244
...mockState.metamask,
4345
customerId: '1',

0 commit comments

Comments
 (0)