From 75f2a6a05a82626854d153087133e7239fa5b8b3 Mon Sep 17 00:00:00 2001 From: Zishan Mohd Date: Wed, 1 Oct 2025 17:25:25 +0530 Subject: [PATCH 01/26] feat: implement UI changes --- src/app/(mobile-ui)/points/page.tsx | 57 +++++++++++++++++++++++++++-- src/services/points.ts | 24 ++++++++++++ src/services/services.types.ts | 11 ++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 src/services/points.ts diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx index 3baee1b5a..6d3b3449b 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -14,6 +14,9 @@ import { invitesApi } from '@/services/invites' import { Invite } from '@/services/services.types' import { useQuery } from '@tanstack/react-query' import { useRouter } from 'next/navigation' +import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' +import Image from 'next/image' +import { pointsApi } from '@/services/points' const PointsPage = () => { const router = useRouter() @@ -24,12 +27,21 @@ const PointsPage = () => { enabled: !!user?.user.userId, }) + const { data: tierInfo, isLoading: isTierInfoLoading } = useQuery({ + queryKey: ['tierInfo', user?.user.userId], + queryFn: () => pointsApi.getTierInfo(), + enabled: !!user?.user.userId, + }) const username = user?.user.username const inviteCode = username ? `${username.toUpperCase()}INVITESYOU` : '' const inviteLink = `${process.env.NEXT_PUBLIC_BASE_URL}/invite?code=${inviteCode}` - if (isLoading) { - return + if (isLoading || isTierInfoLoading) { + return + } + + if (!tierInfo?.data) { + return } return ( @@ -37,6 +49,42 @@ const PointsPage = () => { router.back()} />
+ +

TIER {tierInfo?.data.currentTier}

+ + star + {tierInfo.data.totalPoints} Points + + + {/* Progress bar */} +
+ {tierInfo?.data.currentTier} +
+
+
+
+
+ {tierInfo?.data.currentTier + 1} +
+ +

+ {tierInfo.data.pointsToNextTier} points needed for the next tier +

+ + +
+ + +

+ Do stuff on Peanut and get points. Invite friends and pocket 20% of their points, too. +

+
+

Refer friends

@@ -62,13 +110,14 @@ const PointsPage = () => {
{invites?.map((invite: Invite, i: number) => { const username = invite.invitee.username + const fullName = invite.invitee.fullName const isVerified = invite.invitee.bridgeKycStatus === 'approved' return (
{ size="small" />
-
+

+10 pts

) diff --git a/src/services/points.ts b/src/services/points.ts new file mode 100644 index 000000000..6d41d3bf0 --- /dev/null +++ b/src/services/points.ts @@ -0,0 +1,24 @@ +import { TierInfo } from './services.types' + +export const pointsApi = { + getTierInfo: async (): Promise<{ success: boolean; data: TierInfo }> => { + // Add 2 second delay + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const response = { + userId: 'user_123', + directPoints: 2500, + transitivePoints: 850, + totalPoints: 3350, + currentTier: 2, + leaderboardRank: 42, + nextTierThreshold: 10000, + pointsToNextTier: 6650, + } + + return { + success: true, + data: response, + } + }, +} diff --git a/src/services/services.types.ts b/src/services/services.types.ts index 0ac7d5fab..569f08dda 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -323,3 +323,14 @@ export interface Invite { fullName: string | null } } + +export interface TierInfo { + userId: string + directPoints: number + transitivePoints: number + totalPoints: number + currentTier: number + leaderboardRank: number + nextTierThreshold: number + pointsToNextTier: number +} From e84ad24d0fcb8bec947699516f0f9db6f12f12ef Mon Sep 17 00:00:00 2001 From: Zishan Mohd Date: Thu, 2 Oct 2025 15:27:46 +0530 Subject: [PATCH 02/26] integrate API --- src/app/(mobile-ui)/points/page.tsx | 18 +++++++------- src/services/invites.ts | 23 +++++++++++------- src/services/points.ts | 37 ++++++++++++++++------------- src/services/services.types.ts | 26 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx index 6d3b3449b..fa390ebc7 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -11,7 +11,7 @@ import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionA import { VerifiedUserLabel } from '@/components/UserHeader' import { useAuth } from '@/context/authContext' import { invitesApi } from '@/services/invites' -import { Invite } from '@/services/services.types' +import { Invite, PointsInvite } from '@/services/services.types' import { useQuery } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' @@ -94,7 +94,7 @@ const PointsPage = () => {
- {invites?.length && invites.length > 0 && ( + {invites?.invitees?.length && invites.invitees.length > 0 && ( <> @@ -108,12 +108,12 @@ const PointsPage = () => {

People you invited

- {invites?.map((invite: Invite, i: number) => { - const username = invite.invitee.username - const fullName = invite.invitee.fullName - const isVerified = invite.invitee.bridgeKycStatus === 'approved' + {invites.invitees?.map((invite: PointsInvite, i: number) => { + const username = invite.username + const fullName = invite.fullName + const isVerified = invite.kycStatus === 'approved' return ( - +
{
-

+10 pts

+

+{invite.totalPoints} pts

) @@ -137,7 +137,7 @@ const PointsPage = () => { )} - {invites?.length === 0 && ( + {invites?.invitees?.length === 0 && (
diff --git a/src/services/invites.ts b/src/services/invites.ts index 8b000f630..956ae579a 100644 --- a/src/services/invites.ts +++ b/src/services/invites.ts @@ -2,7 +2,7 @@ import { validateInviteCode } from '@/app/actions/invites' import { PEANUT_API_URL } from '@/constants' import { fetchWithSentry } from '@/utils' import Cookies from 'js-cookie' -import { EInviteType, Invite } from './services.types' +import { EInviteType, PointsInvitesResponse } from './services.types' export const invitesApi = { acceptInvite: async (inviteCode: string, type: EInviteType): Promise<{ success: boolean }> => { @@ -25,22 +25,29 @@ export const invitesApi = { } }, - getInvites: async (): Promise => { + getInvites: async (): Promise => { try { const jwtToken = Cookies.get('jwt-token') - if (!jwtToken) return [] - const response = await fetchWithSentry(`${PEANUT_API_URL}/invites`, { + if (!jwtToken) { + throw new Error('No JWT token found') + } + + const response = await fetchWithSentry(`${PEANUT_API_URL}/points/invites`, { method: 'GET', headers: { Authorization: `Bearer ${jwtToken}`, 'Content-Type': 'application/json', }, }) - if (!response.ok) return [] + if (!response.ok) { + throw new Error('Failed to fetch invites') + } const invitesRes = await response.json().catch(() => ({})) - return invitesRes.invites || [] - } catch { - return [] + + return invitesRes as PointsInvitesResponse + } catch (e) { + console.log(e) + throw new Error('Failed to fetch invites') } }, diff --git a/src/services/points.ts b/src/services/points.ts index 6d41d3bf0..1a1371fab 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -1,24 +1,27 @@ +import Cookies from 'js-cookie' import { TierInfo } from './services.types' +import { fetchWithSentry } from '@/utils' +import { PEANUT_API_URL } from '@/constants' export const pointsApi = { - getTierInfo: async (): Promise<{ success: boolean; data: TierInfo }> => { - // Add 2 second delay - await new Promise((resolve) => setTimeout(resolve, 2000)) + getTierInfo: async (): Promise<{ success: boolean; data: TierInfo | null }> => { + try { + const jwtToken = Cookies.get('jwt-token') + const response = await fetchWithSentry(`${PEANUT_API_URL}/points`, { + method: 'GET', + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + }) + if (!response.ok) { + return { success: false, data: null } + } - const response = { - userId: 'user_123', - directPoints: 2500, - transitivePoints: 850, - totalPoints: 3350, - currentTier: 2, - leaderboardRank: 42, - nextTierThreshold: 10000, - pointsToNextTier: 6650, - } - - return { - success: true, - data: response, + const pointsInfo: TierInfo = await response.json() + return { success: true, data: pointsInfo } + } catch { + return { success: false, data: null } } }, } diff --git a/src/services/services.types.ts b/src/services/services.types.ts index 569f08dda..87cde5769 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -334,3 +334,29 @@ export interface TierInfo { nextTierThreshold: number pointsToNextTier: number } + +export interface PointsInvite { + inviteeId: string + username: string + fullName: string | null + invitedAt: string + kycStatus: BridgeKycStatus | null + kycVerified: boolean + directPoints: number + totalPoints: number + contributedPoints: number + hasInvitedOthers: boolean + inviteesCount: number +} + +export interface PointsInvitesResponse { + invitees: PointsInvite[] + summary: { + multiplier: number + pendingInvites: number + totalContributedPoints: number + totalDirectPoints: number + totalInvites: number + verifiedInvites: number + } +} From d55e8336370e3a31f7f56d46201a6a5948cdf3c7 Mon Sep 17 00:00:00 2001 From: Zishan Mohd Date: Thu, 2 Oct 2025 15:48:07 +0530 Subject: [PATCH 03/26] feat: add calculate points endpoint --- src/services/points.ts | 39 +++++++++++++++++++++++++++++++++- src/services/services.types.ts | 15 +++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/services/points.ts b/src/services/points.ts index 1a1371fab..b5765ad78 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -1,5 +1,5 @@ import Cookies from 'js-cookie' -import { TierInfo } from './services.types' +import { CalculatePointsRequest, PointsAction, TierInfo } from './services.types' import { fetchWithSentry } from '@/utils' import { PEANUT_API_URL } from '@/constants' @@ -24,4 +24,41 @@ export const pointsApi = { return { success: false, data: null } } }, + + calculatePoints: async ({ + actionType, + usdAmount, + otherUserId, + }: CalculatePointsRequest): Promise<{ estimatedPoints: number }> => { + try { + const jwtToken = Cookies.get('jwt-token') + const body: { actionType: PointsAction; usdAmount: number; otherUserId?: string } = { + actionType, + usdAmount, + } + + if (otherUserId) { + body.otherUserId = otherUserId + } + + const response = await fetchWithSentry(`${PEANUT_API_URL}/points/calculate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + throw new Error('Failed to calculate points') + } + + const data = await response.json() + + return { estimatedPoints: data.estimatedPoints } + } catch { + throw new Error('Failed to calculate points') + } + }, } diff --git a/src/services/services.types.ts b/src/services/services.types.ts index 87cde5769..1c6e2749f 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -360,3 +360,18 @@ export interface PointsInvitesResponse { verifiedInvites: number } } + +export enum PointsAction { + BRIDGE_TRANSFER = 'BRIDGE_TRANSFER', + MANTECA_TRANSFER = 'MANTECA_TRANSFER', + MANTECA_QR_PAYMENT = 'MANTECA_QR_PAYMENT', + P2P_SEND_LINK = 'P2P_SEND_LINK', + P2P_REQUEST_PAYMENT = 'P2P_REQUEST_PAYMENT', + INVITE_KYC_VERIFIED = 'INVITE_KYC_VERIFIED', +} + +export interface CalculatePointsRequest { + actionType: PointsAction + usdAmount: number + otherUserId?: string +} From 47ef1f6d9dd6ed42c03ccde546e8d306c178b978 Mon Sep 17 00:00:00 2001 From: Zishan Mohd Date: Thu, 2 Oct 2025 16:37:42 +0530 Subject: [PATCH 04/26] feat: calculate points in txn done flows --- .../withdraw/[country]/bank/page.tsx | 19 +++++++++- src/app/[...recipient]/client.tsx | 19 ++++++++++ .../Claim/Link/Onchain/Success.view.tsx | 38 ++++++++++++++++++- .../Payment/Views/Status.payment.view.tsx | 10 +++++ src/services/points.ts | 5 +++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 0c452f09c..4a238cd4b 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -13,7 +13,7 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { AccountType, Account } from '@/interfaces' import { formatIban, shortenAddressLong, isTxReverted } from '@/utils/general.utils' import { useParams, useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import DirectSuccessView from '@/components/Payment/Views/Status.payment.view' import { ErrorHandler, getBridgeChainName } from '@/utils' import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' @@ -21,6 +21,9 @@ import { createOfframp, confirmOfframp } from '@/app/actions/offramp' import { useAuth } from '@/context/authContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings from '@/constants/countryCurrencyMapping' +import { useQuery } from '@tanstack/react-query' +import { pointsApi } from '@/services/points' +import { PointsAction } from '@/services/services.types' type View = 'INITIAL' | 'SUCCESS' @@ -40,6 +43,19 @@ export default function WithdrawBankPage() { currency.path?.toLowerCase() === country.toLowerCase() )?.currencyCode + const queryKey = useMemo(() => ['calculate-points'], [crypto.randomUUID()]) + + // Calculate points API call + const { data: pointsData } = useQuery({ + queryKey, + queryFn: () => + pointsApi.calculatePoints({ + actionType: PointsAction.BRIDGE_TRANSFER, + usdAmount: Number(amountToWithdraw), + }), + refetchOnWindowFocus: false, + }) + useEffect(() => { if (!bankAccount) { router.replace('/withdraw') @@ -272,6 +288,7 @@ export default function WithdrawBankPage() { isWithdrawFlow currencyAmount={`$${amountToWithdraw}`} message={bankAccount ? shortenAddressLong(bankAccount.identifier.toUpperCase()) : ''} + points={pointsData?.estimatedPoints} /> )}
diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index 159cf9ae8..854cf2443 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -33,6 +33,9 @@ import NavHeader from '@/components/Global/NavHeader' import { ReqFulfillBankFlowManager } from '@/components/Request/views/ReqFulfillBankFlowManager' import SupportCTA from '@/components/Global/SupportCTA' import { BankRequestType, useDetermineBankRequestType } from '@/hooks/useDetermineBankRequestType' +import { useQuery } from '@tanstack/react-query' +import { pointsApi } from '@/services/points' +import { PointsAction } from '@/services/services.types' export type PaymentFlow = 'request_pay' | 'external_wallet' | 'direct_pay' | 'withdraw' interface Props { @@ -70,6 +73,21 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props) } = useRequestFulfillmentFlow() const { requestType } = useDetermineBankRequestType(chargeDetails?.requestLink.recipientAccount.userId ?? '') + // Calculate points API call + const { data: pointsData } = useQuery({ + queryKey: ['calculate-points', chargeDetails?.uuid], + queryFn: () => + pointsApi.calculatePoints({ + actionType: PointsAction.P2P_REQUEST_PAYMENT, + usdAmount: Number(chargeDetails?.tokenAmount), + otherUserId: chargeDetails?.requestLink.recipientAccount.userId, + }), + // Pre fetch points when in confirm view. + // Fetch only for logged in users. + enabled: !!(user?.user.userId && currentView === 'CONFIRM' && flow === 'request_pay'), + refetchOnWindowFocus: false, + }) + // determine if the current user is the recipient of the transaction const isCurrentUserRecipient = chargeDetails?.requestLink.recipientAccount?.userId === user?.user.userId @@ -536,6 +554,7 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props) } isExternalWalletFlow={isExternalWalletFlow} redirectTo={isExternalWalletFlow ? '/add-money' : '/send'} + points={pointsData?.estimatedPoints} /> )} diff --git a/src/components/Claim/Link/Onchain/Success.view.tsx b/src/components/Claim/Link/Onchain/Success.view.tsx index 63cfa3e51..ca0d3186c 100644 --- a/src/components/Claim/Link/Onchain/Success.view.tsx +++ b/src/components/Claim/Link/Onchain/Success.view.tsx @@ -8,15 +8,18 @@ import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useUserStore } from '@/redux/hooks' import { ESendLinkStatus, sendLinksApi } from '@/services/sendLinks' import { formatTokenAmount, getTokenDetails, printableAddress, shortenAddressLong } from '@/utils' -import { useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { useEffect, useMemo } from 'react' import type { Hash } from 'viem' import { formatUnits } from 'viem' import * as _consts from '../../Claim.consts' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO, STAR_STRAIGHT_ICON } from '@/assets' import CreateAccountButton from '@/components/Global/CreateAccountButton' +import { pointsApi } from '@/services/points' +import { PointsAction } from '@/services/services.types' +import PeanutLoading from '@/components/Global/PeanutLoading' export const SuccessClaimLinkView = ({ transactionHash, @@ -31,6 +34,25 @@ export const SuccessClaimLinkView = ({ const queryClient = useQueryClient() const { offrampDetails, claimType, bankDetails } = useClaimBankFlow() + const queryKey = useMemo(() => ['calculate-points'], [crypto.randomUUID()]) + + const { data: pointsData, isLoading: isPointsDataLoading } = useQuery({ + queryKey, + queryFn: () => + pointsApi.calculatePoints({ + actionType: PointsAction.P2P_SEND_LINK, + usdAmount: Number( + formatTokenAmount( + Number(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)) * (tokenPrice ?? 0) + ) + ), + otherUserId: claimLinkData?.senderAddress, + }), + // Fetch only for logged in users. + enabled: !!authUser?.user.userId, + refetchOnWindowFocus: false, + }) + useEffect(() => { queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) }, [queryClient]) @@ -135,6 +157,10 @@ export const SuccessClaimLinkView = ({ return router.push('/setup')} /> } + if (isPointsDataLoading) { + return + } + return (
@@ -149,6 +175,14 @@ export const SuccessClaimLinkView = ({
+ {pointsData && ( +
+ star +

+ You've earned {pointsData.estimatedPoints} points! +

+
+ )} {renderButtons()}
diff --git a/src/components/Payment/Views/Status.payment.view.tsx b/src/components/Payment/Views/Status.payment.view.tsx index 1b16063d2..40e5c27ca 100644 --- a/src/components/Payment/Views/Status.payment.view.tsx +++ b/src/components/Payment/Views/Status.payment.view.tsx @@ -24,6 +24,7 @@ import Image from 'next/image' import { useRouter } from 'next/navigation' import { ReactNode, useEffect, useMemo } from 'react' import { useDispatch } from 'react-redux' +import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' type DirectSuccessViewProps = { user?: ApiUser @@ -37,6 +38,7 @@ type DirectSuccessViewProps = { isWithdrawFlow?: boolean redirectTo?: string onComplete?: () => void + points?: number } const DirectSuccessView = ({ @@ -51,6 +53,7 @@ const DirectSuccessView = ({ isWithdrawFlow, redirectTo = '/home', onComplete, + points, }: DirectSuccessViewProps) => { const router = useRouter() const { chargeDetails, parsedPaymentData, usdAmount, paymentDetails } = usePaymentStore() @@ -231,6 +234,13 @@ const DirectSuccessView = ({
+ {points && ( +
+ star +

You've earned {points} points!

+
+ )} +
{!!authUser?.user.userId ? (