Stop using a hardcoded membership price if loading the pricing fails

This commit is contained in:
ponas committed 2026-07-30 00:17:28 +02:00
1 parent 1069d66e2c
commit ffc9bfcec6
3 files changed
+70 -14

No files matched your search

+31 -2
View File
@@ -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(
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isFetchingUserData={false} onDeletePress={jest.fn()} />,
{
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();
+30 -7
View File
@@ -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 = ({
<Button
onPress={onPurchasePress}
fullWidth
disabled={paymentLoading || isLoadingMembershipType}
disabled={paymentLoading || isLoadingMembershipType || membershipPrice == null}
style={[theme.button, styles.purchaseButton]}
>
<Text style={theme.buttonText}>{`Kjøp medlemskap (${membershipPrice} NOK)`}</Text>
<Text style={theme.buttonText}>
{membershipPrice != null ? `Kjøp medlemskap (${membershipPrice} NOK)` : 'Kjøp medlemskap'}
</Text>
</Button>
{membershipTypesError && (
<>
<Text style={[theme.text, styles.errorMessage]}>Kunne ikke hente priser</Text>
<Button small onPress={onRetryPress} style={[theme.button, styles.retryButton]}>
<Text style={theme.buttonText}>Prøv igjen</Text>
</Button>
</>
)}
<View style={styles.purchaseText}>
<Text style={[theme.text, styles.purchaseTextInner]}>Medlemskapet er gyldig i ett år</Text>
</View>
@@ -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}
/>
<MembershipValidTo user={user} />
@@ -404,6 +423,10 @@ const styles = StyleSheet.create({
purchaseButton: {
marginTop: 8,
},
retryButton: {
alignSelf: 'center',
marginTop: 8,
},
logoutButton: {
marginBottom: 8,
},
+9 -5
View File
@@ -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<ToastProps | null>(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,