diff --git a/app/src/components/NavBar/Points.tsx b/app/src/components/NavBar/Points.tsx index a8f77f0b6..01ea82b9a 100644 --- a/app/src/components/NavBar/Points.tsx +++ b/app/src/components/NavBar/Points.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Pressable, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Button, Image, Text, View, XStack, YStack, ZStack } from 'tamagui'; @@ -54,14 +54,12 @@ const Points: React.FC = () => { const [isNovaSubscribed, setIsNovaSubscribed] = useState(false); const [isEnabling, setIsEnabling] = useState(false); const incomingPoints = useIncomingPoints(); + const { amount: points } = usePoints(); const loadEvents = usePointEventStore(state => state.loadEvents); const { hasCompletedBackupForPoints, setBackupForPointsCompleted } = useSettingStore(); const [isBackingUp, setIsBackingUp] = useState(false); - // Ref to trigger list refresh - const listRefreshRef = useRef<(() => Promise) | null>(null); - const [isContentReady, setIsContentReady] = useState(false); const [isFocused, setIsFocused] = useState(false); @@ -114,10 +112,6 @@ const Points: React.FC = () => { useSettingStore.getState().setBackupForPointsCompleted(); selfClient.trackEvent(PointEvents.EARN_BACKUP_SUCCESS); - if (listRefreshRef.current) { - await listRefreshRef.current(); - } - const callbackId = registerModalCallbacks({ onButtonPress: () => {}, onModalDismiss: () => {}, @@ -156,8 +150,6 @@ const Points: React.FC = () => { loadEvents(); }, [loadEvents]); - const points = usePoints(); - useEffect(() => { const checkSubscription = async () => { const subscribed = await isTopicSubscribed('nova'); @@ -188,10 +180,6 @@ const Points: React.FC = () => { setIsNovaSubscribed(true); selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_SUCCESS); - if (listRefreshRef.current) { - await listRefreshRef.current(); - } - const callbackId = registerModalCallbacks({ onButtonPress: () => {}, onModalDismiss: () => {}, @@ -294,10 +282,6 @@ const Points: React.FC = () => { setBackupForPointsCompleted(); selfClient.trackEvent(PointEvents.EARN_BACKUP_SUCCESS); - if (listRefreshRef.current) { - await listRefreshRef.current(); - } - const callbackId = registerModalCallbacks({ onButtonPress: () => {}, onModalDismiss: () => {}, @@ -441,7 +425,6 @@ const Points: React.FC = () => { {isContentReady && isFocused && ( diff --git a/app/src/components/PointHistoryList.tsx b/app/src/components/PointHistoryList.tsx index d59497881..1378d62bd 100644 --- a/app/src/components/PointHistoryList.tsx +++ b/app/src/components/PointHistoryList.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, RefreshControl, @@ -37,7 +37,6 @@ export type PointHistoryListProps = { | React.ComponentType> | React.ReactElement | null; - onRefreshRef?: React.MutableRefObject<(() => Promise) | null>; onLayout?: () => void; }; @@ -62,28 +61,19 @@ const getIconForEventType = (type: PointEvent['type']) => { export const PointHistoryList: React.FC = ({ ListHeaderComponent, - onRefreshRef, onLayout, }) => { const [refreshing, setRefreshing] = useState(false); // Subscribe to events directly from store - component will auto-update when store changes const pointEvents = usePointEventStore(state => state.getAllPointEvents()); const isLoading = usePointEventStore(state => state.isLoading); - // loadEvents only needs to be called once on mount. ev + const refreshPoints = usePointEventStore(state => state.refreshPoints); + const refreshIncomingPoints = usePointEventStore( + state => state.refreshIncomingPoints, + ); + // loadEvents only needs to be called once on mount. // and it is called in Points.ts - // Expose no-op refresh function to parent via ref for backward compatibility - // Component auto-updates via Zustand, so manual refresh is not needed - // Note: We don't call loadEvents() here as it could cause data loss if called - // while events are being saved (loadEvents should only run at app startup) - useEffect(() => { - if (onRefreshRef) { - onRefreshRef.current = async () => { - // No-op: component auto-updates when store changes - }; - } - }, [onRefreshRef]); - const formatDate = (timestamp: number) => { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', @@ -277,13 +267,13 @@ export const PointHistoryList: React.FC = ({ [], ); - // Pull-to-refresh handler - currently a no-op - // TODO: Implement refresh logic after merge + // Pull-to-refresh handler const onRefresh = useCallback(() => { setRefreshing(true); - // Placeholder for future refresh logic - setTimeout(() => setRefreshing(false), 500); - }, []); + Promise.all([refreshPoints(), refreshIncomingPoints()]).finally(() => + setRefreshing(false), + ); + }, [refreshPoints, refreshIncomingPoints]); const keyExtractor = useCallback((item: PointEvent) => item.id, []); diff --git a/app/src/hooks/usePoints.ts b/app/src/hooks/usePoints.ts index ea1b49235..c9d779bb1 100644 --- a/app/src/hooks/usePoints.ts +++ b/app/src/hooks/usePoints.ts @@ -2,65 +2,52 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { usePointEventStore } from '@/stores/pointEventStore'; -import { - getIncomingPoints, - getNextSundayNoonUTC, - getPointsAddress, - getTotalPoints, - type IncomingPoints, -} from '@/utils/points'; +import { getNextSundayNoonUTC, type IncomingPoints } from '@/utils/points'; /* - * Hook to fetch incoming points for the user. It refetches the incoming points when there is a new event in the point events store. + * Hook to get incoming points for the user. It shows the optimistic incoming points. + * Refreshes incoming points once on mount. */ -export const useIncomingPoints = (): IncomingPoints | null => { - const [incomingPoints, setIncomingPoints] = useState( - null, +export const useIncomingPoints = (): IncomingPoints => { + const incomingPoints = usePointEventStore(state => state.incomingPoints); + const totalOptimisticIncomingPoints = usePointEventStore(state => + state.totalOptimisticIncomingPoints(), + ); + const refreshIncomingPoints = usePointEventStore( + state => state.refreshIncomingPoints, ); - const pointEvents = usePointEventStore(state => state.events); - const pointEventsCount = pointEvents.length; useEffect(() => { - const fetchIncomingPoints = async () => { - try { - const points = await getIncomingPoints(); - setIncomingPoints(points); - } catch (error) { - console.error('Error fetching incoming points:', error); - } - }; - fetchIncomingPoints(); - // when we record a new point event, we want to refetch incoming points - }, [pointEventsCount]); - - return incomingPoints; + // Only refresh once on mount - the store handles promise caching for concurrent calls + refreshIncomingPoints(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps: only run once on mount + + return { + amount: totalOptimisticIncomingPoints, + expectedDate: incomingPoints.expectedDate, + }; }; /* * Hook to fetch total points for the user. It refetches the total points when the next points update time is reached (each Sunday noon UTC). */ export const usePoints = () => { - const [truePoints, setTruePoints] = useState({ - points: 0, - }); + const points = usePointEventStore(state => state.points); const nextPointsUpdate = getNextSundayNoonUTC().getTime(); + const refreshPoints = usePointEventStore(state => state.refreshPoints); useEffect(() => { - const fetchPoints = async () => { - try { - const address = await getPointsAddress(); - const points = await getTotalPoints(address); - setTruePoints({ points }); - } catch (error) { - console.error('Error fetching total points:', error); - } - }; - fetchPoints(); + refreshPoints(); // refresh when points update time changes as its the only time points can change + // eslint-disable-next-line react-hooks/exhaustive-deps }, [nextPointsUpdate]); - return truePoints.points; + return { + amount: points, + refetch: refreshPoints, + }; }; diff --git a/app/src/screens/home/HomeScreen.tsx b/app/src/screens/home/HomeScreen.tsx index 256dba9ab..970a26a43 100644 --- a/app/src/screens/home/HomeScreen.tsx +++ b/app/src/screens/home/HomeScreen.tsx @@ -57,7 +57,7 @@ const HomeScreen: React.FC = () => { >({}); const [loading, setLoading] = useState(true); - const selfPoints = usePoints(); + const { amount: selfPoints } = usePoints(); // Calculate card dimensions exactly like IdCardLayout does const { width: screenWidth } = Dimensions.get('window'); diff --git a/app/src/stores/pointEventStore.ts b/app/src/stores/pointEventStore.ts index fcdc57a55..2e8945c37 100644 --- a/app/src/stores/pointEventStore.ts +++ b/app/src/stores/pointEventStore.ts @@ -5,7 +5,18 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { PointEvent, PointEventType } from '@/utils/points'; +import type { + IncomingPoints, + PointEvent, + PointEventType, +} from '@/utils/points'; +import { + getIncomingPoints, + getNextSundayNoonUTC, + getPointsAddress, + getTotalPoints, +} from '@/utils/points'; +import { pollEventProcessingStatus } from '@/utils/points/eventPolling'; interface PointEventState { events: PointEvent[]; @@ -15,9 +26,23 @@ interface PointEventState { title: string, type: PointEventType, points: number, + id: string, ) => Promise; + markEventAsProcessed: (id: string) => Promise; + markEventAsFailed: (id: string) => Promise; removeEvent: (id: string) => Promise; clearEvents: () => Promise; + getUnprocessedEvents: () => PointEvent[]; + totalOptimisticIncomingPoints: () => number; + incomingPoints: IncomingPoints & { + lastUpdated: number | null; + promise: Promise | null; + }; + // these are the real points that are on chain. each sunday noon UTC they get updated based on incoming points + points: number; + refreshPoints: () => Promise; + fetchIncomingPoints: () => Promise; + refreshIncomingPoints: () => Promise; getAllPointEvents: () => PointEvent[]; } @@ -26,8 +51,25 @@ const STORAGE_KEY = '@point_events'; const DESIRED_EVENT_TYPES = ['refer', 'notification', 'backup', 'disclosure']; export const usePointEventStore = create()((set, get) => ({ + incomingPoints: { + amount: 0, + lastUpdated: null, + promise: null, + expectedDate: getNextSundayNoonUTC(), + }, + points: 0, events: [], isLoading: false, + refreshPoints: async () => { + try { + const address = await getPointsAddress(); + const points = await getTotalPoints(address); + set({ points }); + } catch (error) { + console.error('Error refreshing points:', error); + } + }, + // should only be called once on app startup getAllPointEvents: () => { return get() .events.filter(event => DESIRED_EVENT_TYPES.includes(event.type)) @@ -38,8 +80,49 @@ export const usePointEventStore = create()((set, get) => ({ set({ isLoading: true }); const stored = await AsyncStorage.getItem(STORAGE_KEY); if (stored) { - const events = JSON.parse(stored); - set({ events, isLoading: false }); + try { + const parsed = JSON.parse(stored); + // Validate that parsed data is an array + if (!Array.isArray(parsed)) { + console.error('Invalid stored events format, expected array'); + set({ events: [], isLoading: false }); + return; + } + // Validate each event has required fields + const events: PointEvent[] = parsed.filter((event: unknown) => { + if ( + typeof event === 'object' && + event !== null && + 'id' in event && + 'status' in event && + 'points' in event + ) { + return true; + } + console.warn('Skipping invalid event:', event); + return false; + }) as PointEvent[]; + set({ events, isLoading: false }); + // Resume polling for any pending events that were interrupted by app restart + // (New events are polled immediately in recordEvents.ts when created) + get() + .getUnprocessedEvents() + .forEach(event => { + // Use event.id as job_id (id is the job_id) + pollEventProcessingStatus(event.id).then(result => { + if (result === 'completed') { + get().markEventAsProcessed(event.id); + } else if (result === 'failed') { + get().markEventAsFailed(event.id); + } + }); + }); + } catch (parseError) { + console.error('Error parsing stored events:', parseError); + // Clear corrupted data + await AsyncStorage.removeItem(STORAGE_KEY); + set({ events: [], isLoading: false }); + } } else { set({ isLoading: false }); } @@ -49,23 +132,184 @@ export const usePointEventStore = create()((set, get) => ({ } }, - addEvent: async (title, type, points) => { + fetchIncomingPoints: async () => { + if (get().incomingPoints.promise) { + return await get().incomingPoints.promise; + } + const promise = getIncomingPoints(); + set({ + incomingPoints: { + ...get().incomingPoints, + promise: promise, + }, + }); + try { + const points = await promise; + return points; + } finally { + // Clear promise after completion (success or failure) + // Only clear if it's still the same promise (no concurrent update) + if (get().incomingPoints.promise === promise) { + set({ + incomingPoints: { + ...get().incomingPoints, + promise: null, + }, + }); + } + } + }, + /* + * Fetches incoming points from the backend and updates the store. + * @param otherState Optional additional state to merge into incomingPoints. so they can be updated atomically. + */ + refreshIncomingPoints: async () => { + // Avoid concurrent updates + if (get().incomingPoints.promise) { + return; + } + + // Fetch incoming points + try { + const points = await get().fetchIncomingPoints(); + if (points === null) { + // Fetch failed, promise already cleared by fetchIncomingPoints + return; + } + // points are not saved to local storage as that would lead to stale data + // Refresh expectedDate to ensure it's current + set({ + incomingPoints: { + ...get().incomingPoints, + lastUpdated: Date.now(), + amount: points.amount, + promise: null, // Already cleared by fetchIncomingPoints, but ensure it's null + expectedDate: points.expectedDate, + }, + }); + } catch (error) { + console.error('Error refreshing incoming points:', error); + // Promise already cleared by fetchIncomingPoints in finally block + } + }, + getUnprocessedEvents: () => { + return get().events.filter(event => event.status === 'pending'); + }, + /* + * Calculates the total optimistic incoming points based on the current events. + */ + totalOptimisticIncomingPoints: () => { + const optimisticIncomingPoints = get() + .getUnprocessedEvents() + .reduce((sum, event) => sum + event.points, 0); + return optimisticIncomingPoints + get().incomingPoints.amount; + }, + + addEvent: async (title, type, points, id) => { try { const newEvent: PointEvent = { - id: `${type}-${Date.now()}`, + id, title, type, timestamp: Date.now(), points, + status: 'pending', }; const currentEvents = get().events; const updatedEvents = [newEvent, ...currentEvents]; + // Save to storage first, then update state to maintain consistency await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedEvents)); set({ events: updatedEvents }); } catch (error) { console.error('Error adding point event:', error); + // Don't update state if storage fails - maintain consistency + throw error; // Re-throw so caller knows it failed + } + }, + + markEventAsProcessed: async (id: string) => { + try { + // Re-read events to avoid race conditions with concurrent updates + const currentEvents = get().events; + // Check if event still exists and is still pending + const event = currentEvents.find(e => e.id === id); + if (!event) { + console.warn(`Event ${id} not found when marking as processed`); + return; + } + if (event.status !== 'pending') { + // Already processed, skip + return; + } + + const updatedEvents = currentEvents.map(e => + e.id === id ? { ...e, status: 'completed' as const } : e, + ); + // Fetch fresh incoming points from server while saving events to storage + // points are not saved to local storage as that would lead to stale data + // Use fetchIncomingPoints to reuse promise caching and avoid race conditions + const [points] = await Promise.all([ + get().fetchIncomingPoints(), + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedEvents)), + ]); + + // Re-check events haven't changed during async operations + const latestEvents = get().events; + const latestEvent = latestEvents.find(e => e.id === id); + if (latestEvent && latestEvent.status !== 'pending') { + // Event was already updated by another call, merge updates carefully + const finalEvents = latestEvents.map(e => + e.id === id ? { ...e, status: 'completed' as const } : e, + ); + set({ events: finalEvents }); + } else { + // Atomically update both events and incoming points in single state update + if (points !== null) { + set({ + events: updatedEvents, + incomingPoints: { + ...get().incomingPoints, + promise: null, + lastUpdated: Date.now(), + amount: points.amount, + expectedDate: points.expectedDate, + }, + }); + } else { + // If fetch failed, just update events + set({ events: updatedEvents }); + } + } + } catch (error) { + console.error('Error marking point event as processed:', error); + // Don't update state if storage fails + } + }, + + markEventAsFailed: async (id: string) => { + try { + const currentEvents = get().events; + const event = currentEvents.find(e => e.id === id); + if (!event) { + console.warn(`Event ${id} not found when marking as failed`); + return; + } + if (event.status !== 'pending') { + // Already processed, skip + return; + } + + const updatedEvents = currentEvents.map(e => + e.id === id ? { ...e, status: 'failed' as const } : e, + ); + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedEvents)); + set({ events: updatedEvents }); + } catch (error) { + console.error('Error marking point event as failed:', error); + // Don't update state if storage fails } }, diff --git a/app/src/utils/points/api.ts b/app/src/utils/points/api.ts index c0ce7f24b..b085812ca 100644 --- a/app/src/utils/points/api.ts +++ b/app/src/utils/points/api.ts @@ -91,11 +91,11 @@ const generateSignature = async (address: string): Promise => { * @param body - The request body data * @param errorMessages - Optional custom error messages for specific error codes */ -export const makeApiRequest = async ( +export const makeApiRequest = async ( endpoint: string, body: Record, errorMessages?: Record, -): Promise => { +): Promise> => { try { // Auto-detect signing address from body (referee for referrals, address for other endpoints) const signingAddress = (body.referee as string) || (body.address as string); diff --git a/app/src/utils/points/eventPolling.ts b/app/src/utils/points/eventPolling.ts new file mode 100644 index 000000000..31b0f013d --- /dev/null +++ b/app/src/utils/points/eventPolling.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { checkEventProcessingStatus } from './jobStatus'; + +/** + * Polls the server to check if an event has been processed. + * Checks at: 2s, 4s, 8s, 16s, 32s, 32s, 32s, 32s + * Returns 'completed' if completed, 'failed' if failed, or null if max attempts reached + */ +export async function pollEventProcessingStatus( + id: string, +): Promise<'completed' | 'failed' | null> { + let delay = 2000; // Start at 2 seconds + const maxAttempts = 10; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + await sleep(delay); + + try { + const status = await checkEventProcessingStatus(id); + if (status === 'completed') { + return 'completed'; + } + if (status === 'failed') { + return 'failed'; + } + // If status is 'pending' or null, continue polling + } catch (error) { + console.error(`Error checking event ${id} status:`, error); + // Continue polling even on error + } + + // Exponential backoff, max 32 seconds + delay = Math.min(delay * 2, 32000); + } + + return null; // Gave up after max attempts +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/app/src/utils/points/jobStatus.ts b/app/src/utils/points/jobStatus.ts new file mode 100644 index 000000000..111516529 --- /dev/null +++ b/app/src/utils/points/jobStatus.ts @@ -0,0 +1,45 @@ +import { POINTS_API_BASE_URL } from './api'; + +export type JobStatusResponse = { + job_id: string; + status: 'complete' | 'failed'; +}; + +export async function checkEventProcessingStatus( + jobId: string, +): Promise<'pending' | 'completed' | 'failed' | null> { + try { + const response = await fetch(`${POINTS_API_BASE_URL}/job/${jobId}/status`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + // 102 means pending + if (response.status === 102) { + return 'pending'; + } + + // 404 means job not found - stop polling as it will never be found + if (response.status === 404) { + return 'failed'; + } + + // 200 means completed or failed - check the response body + if (response.status === 200) { + const data: JobStatusResponse = await response.json(); + if (data.status === 'complete') { + return 'completed'; + } + if (data.status === 'failed') { + return 'failed'; + } + } + + return null; + } catch (error) { + console.error(`Error checking job ${jobId} status:`, error); + return null; + } +} diff --git a/app/src/utils/points/recordEvents.ts b/app/src/utils/points/recordEvents.ts index b700d997a..75f14353a 100644 --- a/app/src/utils/points/recordEvents.ts +++ b/app/src/utils/points/recordEvents.ts @@ -3,6 +3,7 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { isSuccessfulStatus } from '@/utils/points/api'; +import { pollEventProcessingStatus } from '@/utils/points/eventPolling'; import { registerBackupPoints, registerNotificationPoints, @@ -13,15 +14,26 @@ import { POINT_VALUES } from '@/utils/points/types'; import { getPointsAddress } from '@/utils/points/utils'; /** - * Shared helper to add an event to the store. + * Shared helper to add an event to the store and start polling for processing. */ -const addEventToStore = async ( +const addEventToStoreAndPoll = async ( title: string, type: PointEventType, points: number, + jobId: string, ): Promise => { const { usePointEventStore } = await import('@/stores/pointEventStore'); - await usePointEventStore.getState().addEvent(title, type, points); + // Use job_id as the event id + await usePointEventStore.getState().addEvent(title, type, points, jobId); + + // Start polling in background - don't await + pollEventProcessingStatus(jobId).then(result => { + if (result === 'completed') { + usePointEventStore.getState().markEventAsProcessed(jobId); + } else if (result === 'failed') { + usePointEventStore.getState().markEventAsFailed(jobId); + } + }); }; /** @@ -37,8 +49,17 @@ export const recordBackupPointEvent = async (): Promise<{ const userAddress = await getPointsAddress(); const response = await registerBackupPoints(userAddress); - if (response.success && isSuccessfulStatus(response.status)) { - await addEventToStore('Secret backed up', 'backup', POINT_VALUES.backup); + if ( + response.success && + isSuccessfulStatus(response.status) && + response.jobId + ) { + await addEventToStoreAndPoll( + 'Secret backed up', + 'backup', + POINT_VALUES.backup, + response.jobId, + ); return { success: true }; } return { success: false, error: response.error }; @@ -64,11 +85,16 @@ export const recordNotificationPointEvent = async (): Promise<{ const userAddress = await getPointsAddress(); const response = await registerNotificationPoints(userAddress); - if (response.success && isSuccessfulStatus(response.status)) { - await addEventToStore( + if ( + response.success && + isSuccessfulStatus(response.status) && + response.jobId + ) { + await addEventToStoreAndPoll( 'Push notifications enabled', 'notification', POINT_VALUES.notification, + response.jobId, ); return { success: true }; } @@ -98,8 +124,17 @@ export const recordReferralPointEvent = async ( const referee = await getPointsAddress(); const response = await registerReferralPoints({ referee, referrer }); - if (response.success && isSuccessfulStatus(response.status)) { - await addEventToStore('Friend referred', 'refer', POINT_VALUES.referee); + if ( + response.success && + isSuccessfulStatus(response.status) && + response.jobId + ) { + await addEventToStoreAndPoll( + 'Friend referred', + 'refer', + POINT_VALUES.referee, + response.jobId, + ); return { success: true }; } return { success: false, error: response.error }; diff --git a/app/src/utils/points/registerEvents.ts b/app/src/utils/points/registerEvents.ts index 11022af5d..26e31dfb0 100644 --- a/app/src/utils/points/registerEvents.ts +++ b/app/src/utils/points/registerEvents.ts @@ -5,15 +5,24 @@ import { IS_DEV_MODE } from '@/utils/devUtils'; import { makeApiRequest, POINTS_API_BASE_URL } from '@/utils/points/api'; +type VerifyActionResponse = { + job_id: string; +}; + /** * Registers backup action with the points API. * * @param userAddress - The user's wallet address - * @returns Promise resolving to operation status and error message if any + * @returns Promise resolving to job_id, operation status and error message if any */ export const registerBackupPoints = async ( userAddress: string, -): Promise<{ success: boolean; status: number; error?: string }> => { +): Promise<{ + success: boolean; + status: number; + error?: string; + jobId?: string; +}> => { const errorMessages: Record = { already_verified: 'You have already backed up your secret for this account.', @@ -22,7 +31,7 @@ export const registerBackupPoints = async ( invalid_address: 'Invalid wallet address. Please check your account.', }; - const response = await makeApiRequest( + const response = await makeApiRequest( '/verify-action', { action: 'secret_backup', @@ -31,18 +40,35 @@ export const registerBackupPoints = async ( errorMessages, ); - return response; + if (response.success && response.data?.job_id) { + return { + success: true, + status: response.status, + jobId: response.data.job_id, + }; + } + + return { + success: false, + status: response.status, + error: response.error, + }; }; /** * Registers push notification action with the points API. * * @param userAddress - The user's wallet address - * @returns Promise resolving to operation status and error message if any + * @returns Promise resolving to job_id, operation status and error message if any */ export const registerNotificationPoints = async ( userAddress: string, -): Promise<{ success: boolean; status: number; error?: string }> => { +): Promise<{ + success: boolean; + status: number; + error?: string; + jobId?: string; +}> => { const errorMessages: Record = { already_verified: 'You have already verified push notifications for this account.', @@ -52,7 +78,7 @@ export const registerNotificationPoints = async ( invalid_address: 'Invalid wallet address. Please check your account.', }; - return makeApiRequest( + const response = await makeApiRequest( '/verify-action', { action: 'push_notification', @@ -60,6 +86,20 @@ export const registerNotificationPoints = async ( }, errorMessages, ); + + if (response.success && response.data?.job_id) { + return { + success: true, + status: response.status, + jobId: response.data.job_id, + }; + } + + return { + success: false, + status: response.status, + error: response.error, + }; }; /** @@ -69,7 +109,7 @@ export const registerNotificationPoints = async ( * * @param referee - The address of the user being referred * @param referrer - The address of the user referring - * @returns Promise resolving to operation status and error message if any + * @returns Promise resolving to job_id, operation status and error message if any */ export const registerReferralPoints = async ({ referee, @@ -77,7 +117,12 @@ export const registerReferralPoints = async ({ }: { referee: string; referrer: string; -}): Promise<{ success: boolean; status: number; error?: string }> => { +}): Promise<{ + success: boolean; + status: number; + error?: string; + jobId?: string; +}> => { // In __DEV__ mode, log the request instead of sending it if (IS_DEV_MODE) { // Redact addresses for security - show first 6 and last 4 characters only @@ -91,18 +136,25 @@ export const registerReferralPoints = async ({ referrer: redactAddress(referrer), }, }); - // Simulate a successful response for testing - return { success: true, status: 200 }; + // Simulate a successful response with mock job_id for testing + return { success: true, status: 200, jobId: 'dev-refer-' + Date.now() }; } try { - const response = await makeApiRequest('/referrals/refer', { - referee, - referrer, - }); + const response = await makeApiRequest( + '/referrals/refer', + { + referee: referee.toLowerCase(), + referrer: referrer.toLowerCase(), + }, + ); - if (response.success) { - return { success: true, status: 200 }; + if (response.success && response.data?.job_id) { + return { + success: true, + status: response.status, + jobId: response.data.job_id, + }; } // For referral endpoint, try to extract message from response diff --git a/app/src/utils/points/types.ts b/app/src/utils/points/types.ts index ac63eb258..e271c3a2b 100644 --- a/app/src/utils/points/types.ts +++ b/app/src/utils/points/types.ts @@ -13,8 +13,11 @@ export type PointEvent = { type: PointEventType; timestamp: number; points: number; + status: PointEventStatus; }; +export type PointEventStatus = 'pending' | 'completed' | 'failed'; + export type PointEventType = 'refer' | 'notification' | 'backup' | 'disclosure'; export const POINT_VALUES = {