diff --git a/src/Navigator.tsx b/src/Navigator.tsx new file mode 100644 index 0000000..dd37cf4 --- /dev/null +++ b/src/Navigator.tsx @@ -0,0 +1,237 @@ +import { Ionicons } from "@expo/vector-icons"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { useTheme } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { BlurView } from "expo-blur"; +import * as WebBrowser from "expo-web-browser"; +import { StyleSheet, useColorScheme } from "react-native"; +import useSWR, { useSWRConfig } from "swr"; + +// import OrganizationTitle from "./components/organizations/OrganizationTitle"; +import { + StackParamList, + CardsStackParamList, + ReceiptsStackParamList, + TabParamList, +} from "./lib/NavigatorParamList"; +import { PaginatedResponse } from "./lib/types/HcbApiObject"; +import Invitation from "./lib/types/Invitation"; +import CardPage from "./pages/cards/card"; +import CardsPage from "./pages/cards/cards"; +import OrderCardPage from "./pages/cards/OrderCard"; +import Home from "./pages/index"; +import InvitationPage from "./pages/Invitation"; +import OrganizationPage from "./pages/organization"; +import AccountNumberPage from "./pages/organization/AccountNumber"; +import OrganizationTeamPage from "./pages/organization/Team"; +import ReceiptsPage from "./pages/Receipts"; +import RenameTransactionPage from "./pages/RenameTransaction"; +import SettingsPage from "./pages/settings/Settings"; +import TransactionPage from "./pages/Transaction"; +import { palette } from "./styles/theme"; + +const Stack = createNativeStackNavigator(); +const CardsStack = createNativeStackNavigator(); +const ReceiptsStack = createNativeStackNavigator(); + +const Tab = createBottomTabNavigator(); + +export default function Navigator() { + const { data: missingReceiptData } = useSWR>( + `user/transactions/missing_receipt`, + ); + const { data: invitations } = useSWR(`user/invitations`); + + const scheme = useColorScheme(); + const { colors: themeColors } = useTheme(); + + const { mutate } = useSWRConfig(); + + return ( + ({ + tabBarIcon: ({ focused, color, size }) => { + let iconName: React.ComponentProps["name"]; + + if (route.name === "Home") { + iconName = focused ? "home" : "home-outline"; + } else if (route.name === "Cards") { + iconName = focused ? "card" : "card-outline"; + } else if (route.name === "Receipts") { + iconName = focused ? "receipt" : "receipt-outline"; + } else if (route.name === "Settings") { + iconName = focused ? "settings" : "settings-outline"; + } else { + throw new Error("unknown route name"); + } + + return ; + }, + // headerStyle: { backgroundColor: themeColors.background }, + headerShown: false, + tabBarStyle: { position: "absolute" }, + tabBarHideOnKeyboard: true, + tabBarBackground: () => ( + + ), + })} + > + + {() => ( + + ( + + WebBrowser.openBrowserAsync( + "https://hackclub.com/hcb/apply", + { + presentationStyle: + WebBrowser.WebBrowserPresentationStyle.POPOVER, + controlsColor: palette.primary, + dismissButtonStyle: "cancel", + }, + ).then(() => { + mutate("user/organizations"); + mutate("user/invitations"); + }) + } + /> + ), + }} + /> + + ({ + // headerTitle: () => , + title: route.params.organization?.name || "Organization", + headerBackTitle: "Back", + })} + component={OrganizationPage} + /> + + + + + + )} + + + {() => ( + + + ({ + title: "Card", + })} + /> + + + + + )} + + + {() => ( + + + + )} + + + + ); +} diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index d05d72b..1a40cf1 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -216,12 +216,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!response.ok) { const errorBody = await response.text(); - console.error( - `Token refresh failed with status ${response.status}`, - new Error(errorBody), - { action: "token_refresh", status: response.status, errorBody }, - ); - try { const errorJson = JSON.parse(errorBody); console.error( diff --git a/src/components/PaymentCard.tsx b/src/components/PaymentCard.tsx index c82a8d3..cd2ca07 100644 --- a/src/components/PaymentCard.tsx +++ b/src/components/PaymentCard.tsx @@ -171,7 +171,7 @@ export default function PaymentCard({ width: "auto", height: 40, tintColor: - card.personalization?.color == "black" ? "white" : undefined, + card.personalization?.color == "black" ? "white" : "black", aspectRatio: logoWidth / logoHeight, }} /> diff --git a/src/components/ReceiptActionSheet.tsx b/src/components/ReceiptActionSheet.tsx index 3e2e0c5..37cdc33 100644 --- a/src/components/ReceiptActionSheet.tsx +++ b/src/components/ReceiptActionSheet.tsx @@ -1,6 +1,8 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; +import React from "react"; +import { findNodeHandle } from "react-native"; import { ALERT_TYPE, Toast } from "react-native-alert-notification"; import useClient from "../lib/client"; @@ -81,61 +83,67 @@ export function useReceiptActionSheet({ }, ); - const handleActionSheet = withOfflineCheck(() => { - const options = ["Camera", "Photo Library", "Document", "Cancel"]; - const cancelButtonIndex = 3; + const handleActionSheet = withOfflineCheck( + (buttonRef?: React.RefObject) => { + const options = ["Camera", "Photo Library", "Document", "Cancel"]; + const cancelButtonIndex = 3; - showActionSheetWithOptions( - { - options, - cancelButtonIndex, - userInterfaceStyle: isDark ? "dark" : "light", - containerStyle: { - backgroundColor: isDark ? "#252429" : "white", + showActionSheetWithOptions( + { + options, + cancelButtonIndex, + userInterfaceStyle: isDark ? "dark" : "light", + containerStyle: { + backgroundColor: isDark ? "#252429" : "white", + }, + textStyle: { + color: isDark ? "white" : "black", + }, + anchor: buttonRef?.current + ? (findNodeHandle(buttonRef.current as React.Component) ?? + undefined) + : undefined, }, - textStyle: { - color: isDark ? "white" : "black", - }, - }, - async (buttonIndex) => { - if (buttonIndex === 0) { - // Take a photo - ImagePicker.requestCameraPermissionsAsync(); - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: "images", - quality: 1, - }); - if (!result.canceled) { - await uploadFile({ - uri: result.assets[0].uri, - fileName: result.assets[0].fileName || undefined, + async (buttonIndex) => { + if (buttonIndex === 0) { + // Take a photo + ImagePicker.requestCameraPermissionsAsync(); + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: "images", + quality: 1, }); + if (!result.canceled) { + await uploadFile({ + uri: result.assets[0].uri, + fileName: result.assets[0].fileName || undefined, + }); + } + } else if (buttonIndex === 1) { + // Pick from photo library + ImagePicker.requestMediaLibraryPermissionsAsync(); + const result = await ImagePicker.launchImageLibraryAsync({ + quality: 1, + allowsMultipleSelection: true, + selectionLimit: 10, + }); + if (!result.canceled && result.assets.length > 0) { + await uploadMultipleFiles(result.assets); + } + } else if (buttonIndex === 2) { + // Pick a document + const result = await DocumentPicker.getDocumentAsync({ + type: ["application/pdf", "image/*"], + copyToCacheDirectory: true, + multiple: true, + }); + if (!result.canceled && result.assets.length > 0) { + await uploadMultipleFiles(result.assets); + } } - } else if (buttonIndex === 1) { - // Pick from photo library - ImagePicker.requestMediaLibraryPermissionsAsync(); - const result = await ImagePicker.launchImageLibraryAsync({ - quality: 1, - allowsMultipleSelection: true, - selectionLimit: 10, - }); - if (!result.canceled && result.assets.length > 0) { - await uploadMultipleFiles(result.assets); - } - } else if (buttonIndex === 2) { - // Pick a document - const result = await DocumentPicker.getDocumentAsync({ - type: ["application/pdf", "image/*"], - copyToCacheDirectory: true, - multiple: true, - }); - if (!result.canceled && result.assets.length > 0) { - await uploadMultipleFiles(result.assets); - } - } - }, - ); - }); + }, + ); + }, + ); return { handleActionSheet, diff --git a/src/components/cards/CardIcon.tsx b/src/components/cards/CardIcon.tsx new file mode 100644 index 0000000..2991779 --- /dev/null +++ b/src/components/cards/CardIcon.tsx @@ -0,0 +1,21 @@ +import Svg, { Path, SvgProps } from "react-native-svg"; + +const CardIcon = (props: SvgProps) => ( + + + + +); + +export default CardIcon; diff --git a/src/components/cards/RepIcon.tsx b/src/components/cards/RepIcon.tsx new file mode 100644 index 0000000..cf8c784 --- /dev/null +++ b/src/components/cards/RepIcon.tsx @@ -0,0 +1,20 @@ +import Svg, { G, Path } from "react-native-svg"; + +const RepIcon = () => ( + + + + + + +); + +export default RepIcon; diff --git a/src/components/receipts/MissingReceiptTransaction.tsx b/src/components/receipts/MissingReceiptTransaction.tsx index c25e22c..99f50eb 100644 --- a/src/components/receipts/MissingReceiptTransaction.tsx +++ b/src/components/receipts/MissingReceiptTransaction.tsx @@ -2,7 +2,7 @@ import { useTheme } from "@react-navigation/native"; import Icon from "@thedev132/hackclub-icons-rn"; import { formatDistanceToNow } from "date-fns"; import * as Haptics from "expo-haptics"; -import { memo, useState } from "react"; +import { memo, useState, useRef } from "react"; import { TouchableOpacity, View, Text, ActivityIndicator } from "react-native"; import Organization from "../../lib/types/Organization"; @@ -30,6 +30,7 @@ function MissingReceiptTransaction({ }) { const { colors: themeColors } = useTheme(); const [loading, setLoading] = useState(false); + const uploadButtonRef = useRef(null); const { handleActionSheet, isOnline } = useReceiptActionSheet({ orgId: transaction.organization.id, @@ -81,6 +82,7 @@ function MissingReceiptTransaction({ handleActionSheet(uploadButtonRef)} disabled={!isOnline || loading} > {loading ? ( diff --git a/src/components/transaction/ReceiptList.tsx b/src/components/transaction/ReceiptList.tsx index f8b4c68..5dd5c27 100644 --- a/src/components/transaction/ReceiptList.tsx +++ b/src/components/transaction/ReceiptList.tsx @@ -4,7 +4,7 @@ import { RouteProp, useRoute, useTheme } from "@react-navigation/native"; import Icon from "@thedev132/hackclub-icons-rn"; import { formatDistanceToNowStrict, parseISO } from "date-fns"; import { Image } from "expo-image"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native"; import { ALERT_TYPE, Toast } from "react-native-alert-notification"; import Animated, { Easing, withTiming, Layout } from "react-native-reanimated"; @@ -73,6 +73,7 @@ function ReceiptList({ transaction }: { transaction: Transaction }) { const hcb = useClient(); const isDark = useIsDark(); const { isOnline, withOfflineCheck } = useOffline(); + const addReceiptButtonRef = useRef(null); const { handleActionSheet, isOnline: actionSheetIsOnline } = useReceiptActionSheet({ @@ -219,7 +220,8 @@ function ReceiptList({ transaction }: { transaction: Transaction }) { /> handleActionSheet(addReceiptButtonRef)} disabled={!actionSheetIsOnline} > { if (tokens) { const now = Date.now(); - if (tokens.expiresAt <= now + 5 * 60 * 1000) { + if ( + tokens.expiresAt <= now + 5 * 60 * 1000 && + tokens.expiresAt > now + 2 * 60 * 1000 + ) { + console.log("Preemptively refreshing token before it expires"); refreshAccessToken().catch((error) => { console.error("Failed to preemptively refresh token", error); }); @@ -448,6 +459,38 @@ export default function AppContent({ keepPreviousData: true, errorRetryCount: 3, errorRetryInterval: 1000, + onErrorRetry: ( + error, + key, + config, + revalidate, + { retryCount }, + ) => { + const errorWithStatus = error as HTTPError; + const status = + errorWithStatus?.status || + errorWithStatus?.response?.status; + + if (status === 401 || status === 403) { + console.log( + `SWR: Not retrying ${key} due to auth error (${status})`, + ); + return; + } + + if (status === 404) { + return; + } + + if (retryCount >= 3) return; + + // Exponential backoff + const timeout = Math.min( + 1000 * Math.pow(2, retryCount), + 5000, + ); + setTimeout(() => revalidate({ retryCount }), timeout); + }, }} > diff --git a/src/core/Navigator.tsx b/src/core/Navigator.tsx index 6657c8c..91904fa 100644 --- a/src/core/Navigator.tsx +++ b/src/core/Navigator.tsx @@ -24,6 +24,7 @@ import { useIsDark } from "../lib/useColorScheme"; import CardPage from "../pages/cards/card"; import CardsPage from "../pages/cards/cards"; import GrantCardPage from "../pages/cards/GrantCard"; +import OrderCardPage from "../pages/cards/OrderCard"; import Home from "../pages/index"; import InvitationPage from "../pages/Invitation"; import OrganizationPage from "../pages/organization"; @@ -316,6 +317,18 @@ export default function Navigator() { component={GrantCardPage} options={() => ({ title: "Card" })} /> + ({ headerBackTitle: "Back", diff --git a/src/lib/NavigatorParamList.ts b/src/lib/NavigatorParamList.ts index 1f58d39..7b23f72 100644 --- a/src/lib/NavigatorParamList.ts +++ b/src/lib/NavigatorParamList.ts @@ -39,6 +39,7 @@ export type CardsStackParamList = { CardList: undefined; Card: { card?: Card; cardId?: string; grantId?: string }; GrantCard: { grantId: string }; + OrderCard: undefined; Transaction: { transactionId: Transaction["id"]; orgId?: Organization["id"]; diff --git a/src/lib/client.ts b/src/lib/client.ts index 0c25331..ff1dc72 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,40 +1,75 @@ import ky from "ky"; -import { useContext, useMemo } from "react"; +import { useContext, useEffect, useRef } from "react"; import AuthContext, { AuthTokens } from "../auth/auth"; type KyResponse = Awaited>; +let globalRefreshInProgress = false; +let globalRefreshPromise: Promise<{ + success: boolean; + newTokens?: AuthTokens; +}> | null = null; + +interface QueuedRequest { + resolve: (value: KyResponse) => void; + reject: (reason?: Error) => void; + retry: (token: string) => Promise; +} + export default function useClient() { const { tokens, refreshAccessToken } = useContext(AuthContext); - return useMemo(() => { - const pendingRetries = new Set(); - let refreshInProgress = false; - let refreshPromise: Promise<{ - success: boolean; - newTokens?: AuthTokens; - }> | null = null; - let queuedRequests: Array<() => Promise> = []; - - const processQueuedRequests = async () => { - const requests = [...queuedRequests]; - queuedRequests = []; - return Promise.all( - requests.map(async (retry) => { + const tokensRef = useRef(tokens); + const refreshAccessTokenRef = useRef(refreshAccessToken); + const clientRef = useRef | null>(null); + const queuedRequestsRef = useRef([]); + const pendingRetriesRef = useRef>(new Set()); + + useEffect(() => { + tokensRef.current = tokens; + }, [tokens]); + + useEffect(() => { + refreshAccessTokenRef.current = refreshAccessToken; + }, [refreshAccessToken]); + + if (!clientRef.current) { + const processQueuedRequests = async (freshToken: string) => { + const requests = [...queuedRequestsRef.current]; + queuedRequestsRef.current = []; + + console.log( + `Processing ${requests.length} queued requests after token refresh`, + ); + + await Promise.all( + requests.map(async ({ resolve, reject, retry }) => { try { - return await retry(); + const response = await retry(freshToken); + resolve(response); } catch (error) { console.error("Failed to process queued request", error, { context: "queue_processing", }); - throw error; + reject(error); } }), ); }; - const client = ky.create({ + const extractPath = (url: string): string => { + const apiBase = process.env.EXPO_PUBLIC_API_BASE || ""; + let path = url.startsWith(apiBase) ? url.substring(apiBase.length) : url; + + if (path.startsWith("/")) { + path = path.substring(1); + } + + return path; + }; + + clientRef.current = ky.create({ prefixUrl: process.env.EXPO_PUBLIC_API_BASE, retry: { limit: 0, @@ -46,44 +81,9 @@ export default function useClient() { hooks: { beforeRequest: [ async (request) => { - if (refreshInProgress) { - // If refresh is in progress, queue this request - return new Promise(() => { - queuedRequests.push(async () => { - try { - const url = request.url.toString(); - const apiBase = process.env.EXPO_PUBLIC_API_BASE; - let path = url.startsWith(apiBase) - ? url.substring(apiBase.length) - : url; - - if (path.startsWith("/")) { - path = path.substring(1); - } - - const newResponse = await client(path, { - method: request.method, - headers: { - Authorization: `Bearer ${tokens?.accessToken}`, - }, - body: request.body, - }); - return newResponse; - } catch (error) { - console.error("Failed to process queued request", error, { - context: "auth_retry", - }); - throw error; - } - }); - }); - } - - if (tokens?.accessToken) { - request.headers.set( - "Authorization", - `Bearer ${tokens.accessToken}`, - ); + const currentToken = tokensRef.current?.accessToken; + if (currentToken) { + request.headers.set("Authorization", `Bearer ${currentToken}`); } }, ], @@ -92,120 +92,128 @@ export default function useClient() { if (response.ok) return response; const requestKey = `${request.method}:${request.url}`; - if (pendingRetries.has(requestKey)) { + + if (pendingRetriesRef.current.has(requestKey)) { console.log( "Request already being retried, returning response as-is to avoid loop", ); - pendingRetries.delete(requestKey); + pendingRetriesRef.current.delete(requestKey); return response; } if (response.status === 401) { console.log("Received 401 response, attempting token refresh..."); + pendingRetriesRef.current.add(requestKey); - if (refreshInProgress && refreshPromise) { - // Wait for the ongoing refresh, then retry this request - console.log( - "Refresh already in progress, waiting for it to complete...", - ); - try { - const result = await refreshPromise; - - if (result.success && result.newTokens) { - const url = request.url.toString(); - const apiBase = process.env.EXPO_PUBLIC_API_BASE; - let path = url.startsWith(apiBase) - ? url.substring(apiBase.length) - : url; - - if (path.startsWith("/")) { - path = path.substring(1); - } + try { + // If refresh is already in progress, queue this request + if (globalRefreshInProgress) { + console.log("Token refresh in progress, queueing request"); + + pendingRetriesRef.current.delete(requestKey); + + return new Promise((resolve, reject) => { + const path = extractPath(request.url.toString()); + queuedRequestsRef.current.push({ + resolve, + reject, + retry: async (freshToken: string) => { + return clientRef.current!(path, { + method: request.method, + headers: { + Authorization: `Bearer ${freshToken}`, + }, + body: request.body, + }); + }, + }); + }); + } - console.log( - `Retrying request after waiting for refresh: ${path}`, - ); - const newResponse = await client(path, { - method: request.method, - headers: { - Authorization: `Bearer ${result.newTokens.accessToken}`, + if (globalRefreshInProgress && globalRefreshPromise) { + console.log( + "Another request started refresh, waiting for it...", + ); + pendingRetriesRef.current.delete(requestKey); + + return new Promise((resolve, reject) => { + const path = extractPath(request.url.toString()); + queuedRequestsRef.current.push({ + resolve, + reject, + retry: async (freshToken: string) => { + return clientRef.current!(path, { + method: request.method, + headers: { + Authorization: `Bearer ${freshToken}`, + }, + body: request.body, + }); }, - body: request.body, }); - return newResponse; - } - } catch (error) { - console.error("Failed to retry after refresh", error, { - context: "wait_for_refresh_retry", }); - return response; } - } - refreshInProgress = true; - try { - if (!refreshPromise) { - refreshPromise = refreshAccessToken(); + globalRefreshInProgress = true; + + if (!globalRefreshPromise) { + globalRefreshPromise = refreshAccessTokenRef.current(); } - const result = await refreshPromise; + + const result = await globalRefreshPromise; if (result.success && result.newTokens) { - if (result.newTokens.accessToken !== tokens?.accessToken) { - console.log( - "Token refreshed, processing all queued requests", - ); + console.log( + "Token refresh successful, processing queued requests", + ); - await processQueuedRequests(); + const newToken = result.newTokens.accessToken; - const url = request.url.toString(); - const apiBase = process.env.EXPO_PUBLIC_API_BASE; - let path = url.startsWith(apiBase) - ? url.substring(apiBase.length) - : url; + // Only process queue if we have requests + if (queuedRequestsRef.current.length > 0) { + await processQueuedRequests(newToken); + } - if (path.startsWith("/")) { - path = path.substring(1); - } + const path = extractPath(request.url.toString()); - console.log(`Retrying path: ${path}`); + console.log(`Retrying original request: ${path}`); + + try { + const newResponse = await clientRef.current!(path, { + method: request.method, + headers: { + Authorization: `Bearer ${newToken}`, + }, + body: request.body, + }); - const latestAccessToken = result.newTokens.accessToken; console.log( - `Using directly returned token (first 10 chars): ${latestAccessToken.substring(0, 10)}...`, + `Retry succeeded with status: ${newResponse.status}`, ); - - try { - const newResponse = await client(path, { - method: request.method, - headers: { - Authorization: `Bearer ${latestAccessToken}`, - }, - body: request.body, - }); - - console.log( - `Retry succeeded with status: ${newResponse.status}`, - ); - pendingRetries.delete(requestKey); - return newResponse; - } catch (innerError) { - console.error("Inner retry request failed", innerError, { - context: "inner_retry", - }); - pendingRetries.delete(requestKey); - return response; - } + pendingRetriesRef.current.delete(requestKey); + return newResponse; + } catch (innerError) { + pendingRetriesRef.current.delete(requestKey); + throw innerError; } + } else { + console.error("Token refresh failed, returning 401 response"); + pendingRetriesRef.current.delete(requestKey); + return response; } } catch (refreshError) { - console.error( - "Error during token refresh - user will be logged out", - refreshError, - { context: "token_refresh" }, - ); + pendingRetriesRef.current.delete(requestKey); + + const requests = [...queuedRequestsRef.current]; + queuedRequestsRef.current = []; + requests.forEach(({ reject }) => { + reject(new Error("Token refresh failed")); + }); + + return response; } finally { - refreshInProgress = false; - refreshPromise = null; + globalRefreshInProgress = false; + globalRefreshPromise = null; } } @@ -214,7 +222,7 @@ export default function useClient() { ], }, }); + } - return client; - }, [tokens, refreshAccessToken]); + return clientRef.current; } diff --git a/src/lib/types/CardDesign.ts b/src/lib/types/CardDesign.ts new file mode 100644 index 0000000..d32f945 --- /dev/null +++ b/src/lib/types/CardDesign.ts @@ -0,0 +1,9 @@ +export default interface CardDesign { + id: string; + name: string; + color: string; + status: string; + unlisted: boolean; + common: boolean; + logo_url: string; +} diff --git a/src/lib/types/User.ts b/src/lib/types/User.ts index 5aacf86..8fb0672 100644 --- a/src/lib/types/User.ts +++ b/src/lib/types/User.ts @@ -6,9 +6,18 @@ export default interface User extends Omit, "created_at"> { avatar?: string; admin: boolean; auditor: boolean; + birthday?: string; + shipping_address?: { + address_line1: string; + address_line2?: string; + city: string; + state: string; + postal_code: string; + country: string; + }; } export interface OrgUser extends User { joined_at: string; - role?: "member" | "manager"; + role?: "member" | "manager" | "reader"; } diff --git a/src/lib/useOfflineSWR.ts b/src/lib/useOfflineSWR.ts index b585cab..a7bef8e 100644 --- a/src/lib/useOfflineSWR.ts +++ b/src/lib/useOfflineSWR.ts @@ -2,6 +2,13 @@ import useSWR, { SWRConfiguration, SWRResponse } from "swr"; import { useOffline } from "./useOffline"; +interface HTTPError extends Error { + status?: number; + response?: { + status?: number; + }; +} + /** * Custom SWR hook that handles offline scenarios gracefully * - Only fetches when online @@ -43,6 +50,32 @@ export function useOfflineSWR( } } }, + onErrorRetry: (error, key, config, revalidate, { retryCount }) => { + if (!isOnline) { + return; + } + + const errorWithStatus = error as HTTPError; + const status = + errorWithStatus?.status || errorWithStatus?.response?.status; + + if (status === 401 || status === 403) { + console.log( + `useOfflineSWR: Not retrying ${key} due to auth error (${status})`, + ); + return; + } + + if (status === 404) { + return; + } + + if (retryCount >= 3) return; + + // Exponential backoff + const timeout = Math.min(1000 * Math.pow(2, retryCount), 5000); + setTimeout(() => revalidate({ retryCount }), timeout); + }, ...swrOptions, }); diff --git a/src/pages/Receipts.tsx b/src/pages/Receipts.tsx index 9cf793c..391a0dc 100644 --- a/src/pages/Receipts.tsx +++ b/src/pages/Receipts.tsx @@ -4,7 +4,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import Icon from "@thedev132/hackclub-icons-rn"; import { formatDistanceToNowStrict, parseISO } from "date-fns"; import * as ImagePicker from "expo-image-picker"; -import { useState, useMemo, useLayoutEffect } from "react"; +import { useState, useMemo, useLayoutEffect, useRef } from "react"; import { ActivityIndicator, RefreshControl, @@ -119,6 +119,7 @@ export default function ReceiptsPage({ navigation }: Props) { ); const isDark = useIsDark(); const hcb = useClient(); + const uploadButtonRef = useRef(null); // Set navigation title useLayoutEffect(() => { @@ -177,7 +178,7 @@ export default function ReceiptsPage({ navigation }: Props) { }); const handleReceiptUpload = () => { - handleActionSheet(); + handleActionSheet(uploadButtonRef); }; const handleDeleteReceipt = async (receiptId: string) => { @@ -389,6 +390,7 @@ export default function ReceiptsPage({ navigation }: Props) { }} > ( `card_grants/cdg_${grantId}`, ); + const { data: user } = useOfflineSWR(`user`); const { colors: themeColors } = useTheme(); const hcb = useClient(); const [isActivating, setIsActivating] = useState(false); - + const isGrantCardholder = grant?.user?.id === user?.id; const handleActivateGrant = async () => { setIsActivating(true); try { @@ -108,7 +110,7 @@ export default function GrantCardPage({ route, navigation }: Props) { {/* Show activate button for grants that are active but not yet activated */} - {grant.status === "active" && ( + {grant.status === "active" && isGrantCardholder && (