From ffc9bfcec6b78de43ac4c70483423636eb8d9440 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Thu, 30 Jul 2026 00:17:28 +0200 Subject: [PATCH] Stop using a hardcoded membership price if loading the pricing fails --- src/modules/membership/Proof.test.tsx | 33 ++++++++++++++++++++++-- src/modules/membership/Proof.tsx | 37 ++++++++++++++++++++++----- src/modules/membership/useProof.tsx | 14 ++++++---- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index 34a5978..f6e57af 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -1,4 +1,5 @@ import { gql } from '@apollo/client'; +import type { MockedResponse } from '@apollo/client/testing'; import { fireEvent, waitFor } from '@testing-library/react-native'; import * as Notifications from 'expo-notifications'; import React from 'react'; @@ -56,12 +57,16 @@ const expiredMember: UserType = { last_membership: { membership_type: 'standard', end_date: '2020-01-01' }, }; -const renderProof = ({ user = nonMember, fetchUser = jest.fn() }: { user?: UserType; fetchUser?: () => void } = {}) => +const renderProof = ({ + user = nonMember, + fetchUser = jest.fn(), + mocks = apolloMocks, +}: { user?: UserType; fetchUser?: () => void; mocks?: readonly MockedResponse[] } = {}) => renderWithProviders( , { preloadedState: { isAuthenticated: true, userToken: 'test-token', user }, - apolloMocks, + apolloMocks: mocks, }, ); @@ -70,6 +75,30 @@ beforeEach(() => { (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined' }); }); +it('disables the purchase button without showing a price while membership types load', async () => { + const { getByRole, findByText } = renderProof(); + + // The label carries no amount until the query resolves + expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled(); + + // Once the query resolves the real price takes over + expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy(); +}); + +it('shows an error with retry instead of a price when membership types fail to load', async () => { + const { findByText, getByText, getByRole } = renderProof({ + mocks: [{ request: { query: PROOF_QUERY }, error: new Error('network down') }, ...apolloMocks], + }); + + expect(await findByText('Kunne ikke hente priser')).toBeTruthy(); + expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled(); + + // Retrying consumes the queued successful mock and recovers the real price + fireEvent.press(getByText('Prøv igjen')); + expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy(); + expect(getByRole('button', { name: 'Kjøp medlemskap (300 NOK)' })).toBeEnabled(); +}); + it('purchases the preselected membership through the stripe payment sheet', async () => { mockPaymentSheetResponse(); const { findByText } = renderProof(); diff --git a/src/modules/membership/Proof.tsx b/src/modules/membership/Proof.tsx index 5853b93..a309531 100644 --- a/src/modules/membership/Proof.tsx +++ b/src/modules/membership/Proof.tsx @@ -1,5 +1,5 @@ import * as Notifications from 'expo-notifications'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { Image, ScrollView, @@ -79,8 +79,10 @@ interface PurchaseButtonProps { user: UserType; paymentLoading: boolean; isLoadingMembershipType: boolean; - membershipPrice: number; + membershipPrice: number | null; + membershipTypesError: boolean; onPurchasePress: () => void; + onRetryPress: () => void; setMembershipTypeSlug: (value: string) => void; } @@ -90,6 +92,8 @@ const PurchaseButton = ({ isLoadingMembershipType, onPurchasePress, membershipPrice, + membershipTypesError, + onRetryPress, setMembershipTypeSlug, }: PurchaseButtonProps) => { const options = [ @@ -104,9 +108,6 @@ const PurchaseButton = ({ setMembershipTypeSlug(value); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => onSelect(defaultValue.value), []); - if (user.is_member) { return null; } @@ -121,11 +122,21 @@ const PurchaseButton = ({ + {membershipTypesError && ( + <> + Kunne ikke hente priser + + + )} Medlemskapet er gyldig i ett år @@ -256,6 +267,8 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: isLoadingMembershipType, onPurchasePress, membershipPrice, + membershipTypesError, + refetchMembershipTypes, toast, setToast, user, @@ -314,7 +327,13 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: paymentLoading={paymentLoading} isLoadingMembershipType={isLoadingMembershipType} membershipPrice={membershipPrice} + membershipTypesError={membershipTypesError} onPurchasePress={onPurchasePress} + onRetryPress={() => { + refetchMembershipTypes().catch(() => { + // the failure is surfaced through useQuery's error state + }); + }} setMembershipTypeSlug={setMembershipTypeSlug} /> @@ -404,6 +423,10 @@ const styles = StyleSheet.create({ purchaseButton: { marginTop: 8, }, + retryButton: { + alignSelf: 'center', + marginTop: 8, + }, logoutButton: { marginBottom: 8, }, diff --git a/src/modules/membership/useProof.tsx b/src/modules/membership/useProof.tsx index b17b173..2a0f4f5 100644 --- a/src/modules/membership/useProof.tsx +++ b/src/modules/membership/useProof.tsx @@ -24,18 +24,20 @@ interface UseProofProps { const useProof = ({ fetchUser }: UseProofProps) => { const user = useAppSelector((state) => state.user); const userToken = useAppSelector((state) => state.userToken); - const { data, loading } = useQuery(PROOF_QUERY); + const { data, loading, error, refetch } = useQuery(PROOF_QUERY); const { initPaymentSheet, presentPaymentSheet } = useStripe(); const { scheduleMembershipExpiryNotification, hasPermission } = useNotifications(); const [paymentLoading, setPaymentLoading] = useState(false); - const [membershipTypeSlug, setMembershipTypeSlug] = useState('standard'); + // Matches the preselected option in PurchaseButton's radio list + const [membershipTypeSlug, setMembershipTypeSlug] = useState('regular'); const [toast, setToast] = useState(null); - // WARNING: Hardcoded fallback price for when the query hasn't loaded yet. Should probably be fixed. + // null until the query has delivered a price for the selected type; the + // purchase button stays disabled rather than showing a guessed amount const membershipPrice = data?.membershipTypes?.find( (membershipTypeOption: { slug: string; price: number }) => membershipTypeOption.slug === membershipTypeSlug, - )?.price ?? 25000; + )?.price ?? null; const openPaymentSheet = async () => { setPaymentLoading(true); @@ -94,7 +96,9 @@ const useProof = ({ fetchUser }: UseProofProps) => { return { isLoadingMembershipType: loading, - membershipPrice: membershipPrice ? membershipPrice / 100 : 0, + membershipPrice: membershipPrice != null ? membershipPrice / 100 : null, + membershipTypesError: !!error, + refetchMembershipTypes: refetch, onPurchasePress: openPaymentSheet, paymentLoading, toast,