Skip to content

Commit e43a1b9

Browse files
VGR-GITsophieqgu
authored andcommitted
feat: rwds-276 wip
1 parent bc0aaeb commit e43a1b9

File tree

11 files changed

+385
-1
lines changed

11 files changed

+385
-1
lines changed

app/_locales/en/messages.json

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

app/scripts/metamask-controller.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2445,6 +2445,16 @@ export default class MetamaskController extends EventEmitter {
24452445
this.subscriptionController,
24462446
),
24472447

2448+
// rewards
2449+
getRewardsSeasonMetadata: this.controllerMessenger.call.bind(
2450+
this.controllerMessenger,
2451+
'RewardsController:getSeasonMetadata',
2452+
),
2453+
getRewardsSeasonStatus: this.controllerMessenger.call.bind(
2454+
this.controllerMessenger,
2455+
'RewardsController:getSeasonStatus',
2456+
),
2457+
24482458
// hardware wallets
24492459
connectHardware: this.connectHardware.bind(this),
24502460
forgetDevice: this.forgetDevice.bind(this),
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { Box, Text, TextVariant } from '@metamask/design-system-react';
4+
import { useI18nContext } from '../../../hooks/useI18nContext';
5+
import { getIntlLocale } from '../../../ducks/locale/locale';
6+
import { useRewardsContext } from '../../../contexts/rewards';
7+
import { Skeleton } from '../../component-library/skeleton';
8+
9+
/**
10+
* Component to display the rewards points balance
11+
* Shows the points balance with an icon for users who haven't opted in yet
12+
* (i.e., when rewardsActiveAccount?.subscriptionId is null)
13+
*/
14+
export const RewardsPointsBalance = () => {
15+
const t = useI18nContext();
16+
const locale = useSelector(getIntlLocale);
17+
const { rewardsEnabled, seasonStatus, seasonStatusLoading } =
18+
useRewardsContext();
19+
20+
if (!rewardsEnabled) {
21+
return null;
22+
}
23+
24+
if (seasonStatusLoading && !seasonStatus?.balance) {
25+
return <Skeleton />;
26+
}
27+
28+
// Don't render if there's no points balance to show
29+
if (seasonStatus?.balance?.total === null) {
30+
return null;
31+
}
32+
33+
// Format the points balance with proper locale-aware number formatting
34+
const formattedPoints = new Intl.NumberFormat(locale).format(
35+
seasonStatus?.balance?.total ?? 0,
36+
);
37+
38+
return (
39+
<Box
40+
className="flex items-center gap-1"
41+
data-testid="rewards-points-balance"
42+
>
43+
<img
44+
src="./images/metamask-rewards-points.svg"
45+
alt={t('rewardsPointsIcon')}
46+
style={{ width: '20px', height: '20px' }}
47+
/>
48+
<Text
49+
variant={TextVariant.BodyMd}
50+
data-testid="rewards-points-balance-value"
51+
>
52+
{t('rewardsPointsBalance', [formattedPoints])}
53+
</Text>
54+
</Box>
55+
);
56+
};

ui/components/app/rewards/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { RewardsPointsBalance } from './RewardsPointsBalance';

ui/contexts/rewards/index.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { useContext } from 'react';
2+
import type { SeasonStatusState } from '../../../app/scripts/controllers/rewards/rewards-controller.types';
3+
import { useCandidateSubscriptionId } from '../../hooks/rewards/useCandidateSubscriptionId';
4+
import { useSeasonStatus } from '../../hooks/rewards/useSeasonStatus';
5+
import { useRewardsEnabled } from '../../hooks/rewards/useRewardsEnabled';
6+
7+
export interface RewardsContextValue {
8+
rewardsEnabled: boolean;
9+
candidateSubscriptionId: string | null;
10+
candidateSubscriptionIdError: boolean;
11+
seasonStatus: SeasonStatusState | null;
12+
seasonStatusError: string | null;
13+
seasonStatusLoading: boolean;
14+
refetchSeasonStatus: () => Promise<void>;
15+
}
16+
17+
export const RewardsContext = React.createContext<RewardsContextValue>({
18+
rewardsEnabled: false,
19+
candidateSubscriptionId: null,
20+
candidateSubscriptionIdError: false,
21+
seasonStatus: null,
22+
seasonStatusError: null,
23+
seasonStatusLoading: false,
24+
refetchSeasonStatus: async () => {
25+
// Default empty function
26+
},
27+
});
28+
29+
export const useRewardsContext = () => {
30+
const context = useContext(RewardsContext);
31+
if (!context) {
32+
throw new Error('useRewardsContext must be used within a RewardsProvider');
33+
}
34+
return context;
35+
};
36+
37+
export const RewardsProvider: React.FC = ({ children }) => {
38+
const rewardsEnabled = useRewardsEnabled();
39+
const {
40+
candidateSubscriptionId,
41+
candidateSubscriptionIdError,
42+
fetchCandidateSubscriptionId,
43+
} = useCandidateSubscriptionId();
44+
45+
const {
46+
seasonStatus,
47+
seasonStatusError,
48+
seasonStatusLoading,
49+
fetchSeasonStatus,
50+
} = useSeasonStatus({
51+
subscriptionId: candidateSubscriptionId,
52+
onAuthorizationError: fetchCandidateSubscriptionId,
53+
});
54+
55+
return (
56+
<RewardsContext.Provider
57+
value={{
58+
rewardsEnabled,
59+
candidateSubscriptionId,
60+
candidateSubscriptionIdError,
61+
seasonStatus,
62+
seasonStatusError,
63+
seasonStatusLoading,
64+
refetchSeasonStatus: fetchSeasonStatus,
65+
}}
66+
>
67+
{children}
68+
</RewardsContext.Provider>
69+
);
70+
};

ui/hooks/rewards/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { useRewardsContext } from '../../contexts/rewards';
2+
export { useCandidateSubscriptionId } from './useCandidateSubscriptionId';
3+
export { useSeasonStatus } from './useSeasonStatus';
4+
export { useRewardsEnabled } from './useRewardsEnabled';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useState, useCallback, useEffect } from 'react';
2+
import log from 'loglevel';
3+
import { submitRequestToBackground } from '../../store/background-connection';
4+
import { useSelector } from 'react-redux';
5+
import { getIsUnlocked } from '../../ducks/metamask/metamask';
6+
import { useRewardsEnabled } from './useRewardsEnabled';
7+
8+
interface UseCandidateSubscriptionIdReturn {
9+
candidateSubscriptionId: string | null;
10+
candidateSubscriptionIdError: boolean;
11+
fetchCandidateSubscriptionId: () => Promise<void>;
12+
}
13+
14+
/**
15+
* Hook to fetch and manage candidate subscription ID
16+
*/
17+
export const useCandidateSubscriptionId =
18+
(): UseCandidateSubscriptionIdReturn => {
19+
const [candidateSubscriptionId, setCandidateSubscriptionId] = useState<
20+
string | null
21+
>(null);
22+
const [candidateSubscriptionIdError, setCandidateSubscriptionIdError] =
23+
useState(false);
24+
const isUnlocked = useSelector(getIsUnlocked);
25+
const isRewardsEnabled = useRewardsEnabled();
26+
27+
const fetchCandidateSubscriptionId = useCallback(async () => {
28+
try {
29+
const candidateId = await submitRequestToBackground<string>(
30+
'getCandidateSubscriptionId',
31+
[],
32+
);
33+
setCandidateSubscriptionId(candidateId);
34+
setCandidateSubscriptionIdError(false);
35+
} catch (error) {
36+
log.error(
37+
'[useCandidateSubscriptionId] Error fetching candidate subscription ID:',
38+
error,
39+
);
40+
setCandidateSubscriptionIdError(true);
41+
}
42+
}, []);
43+
44+
// Fetch candidate subscription ID on mount and when unlocked
45+
useEffect(() => {
46+
if (!isRewardsEnabled) {
47+
return;
48+
}
49+
50+
if (isUnlocked) {
51+
fetchCandidateSubscriptionId();
52+
}
53+
}, [isUnlocked, isRewardsEnabled, fetchCandidateSubscriptionId]);
54+
55+
return {
56+
candidateSubscriptionId,
57+
candidateSubscriptionIdError,
58+
fetchCandidateSubscriptionId,
59+
};
60+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useMemo } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { getUseExternalServices } from '../../selectors';
4+
import { getRemoteFeatureFlags } from '../../selectors/remote-feature-flags';
5+
import {
6+
validatedVersionGatedFeatureFlag,
7+
VersionGatedFeatureFlag,
8+
} from '../../../shared/lib/feature-flags/version-gating';
9+
10+
/**
11+
* Custom hook to check if rewards feature is enabled.
12+
* Follows the same logic as the RewardsController's isDisabled function.
13+
*
14+
* @returns boolean - True if rewards feature is enabled, false otherwise
15+
*/
16+
export const useRewardsEnabled = (): boolean => {
17+
const remoteFeatureFlags = useSelector(getRemoteFeatureFlags);
18+
const useExternalServices = useSelector(getUseExternalServices);
19+
20+
const isRewardsEnabled = useMemo(() => {
21+
const rewardsFeatureFlag = remoteFeatureFlags?.rewardsEnabled as
22+
| VersionGatedFeatureFlag
23+
| boolean
24+
| undefined;
25+
26+
// Resolve the feature flag (can be boolean or VersionGatedFeatureFlag)
27+
const resolveFlag = (flag: unknown): boolean => {
28+
if (typeof flag === 'boolean') {
29+
return flag;
30+
}
31+
return Boolean(
32+
validatedVersionGatedFeatureFlag(flag as VersionGatedFeatureFlag),
33+
);
34+
};
35+
36+
const featureFlagEnabled = resolveFlag(rewardsFeatureFlag);
37+
38+
// Rewards are enabled when BOTH feature flag is enabled AND useExternalServices is true
39+
return featureFlagEnabled && Boolean(useExternalServices);
40+
}, [remoteFeatureFlags, useExternalServices]);
41+
42+
return isRewardsEnabled;
43+
};

0 commit comments

Comments
 (0)