Fakultas Ilmu Komputer UI

Commit 48cdfb25 authored by Wulan Mantiri's avatar Wulan Mantiri Committed by Muzaki Azami
Browse files

Implement Checkout page layout and integrate with API

parent 16ed516f
......@@ -3,4 +3,5 @@ export default {
cartId: 'CART_ID',
authToken: 'AUTH_TOKEN',
refreshToken: 'REFRESH_TOKEN',
programRecommendations: 'PROGRAM_RECOMMENDATIONS',
};
import * as ROUTES from 'constants/routes';
import {
AllAccessQuestionnaire,
Checkout,
ChoosePlan,
ComingSoonPage,
DietelaQuizResult,
......@@ -38,9 +39,9 @@ export const navigation: NavRoute[] = [
header: 'Choose Plan',
},
{
name: ROUTES.cart,
component: ComingSoonPage,
header: 'Cart',
name: ROUTES.checkout,
component: Checkout,
header: 'Checkout',
},
{
name: ROUTES.programDetail,
......
......@@ -5,10 +5,10 @@ const questionnaire = 'questionnaire';
export const allAccessQuestionnaire = `${questionnaire}/all-access`;
export const dietelaQuizResult = `${questionnaire}/dietela-quiz-result`;
export const cart = 'cart';
export const choosePlan = `${cart}/choose-plan`;
export const programDetail = 'dietela-program';
export const nutritionistDetail = 'nutritionist';
export const checkout = 'checkout';
export const choosePlan = `${checkout}/choose-plan`;
export const programDetail = `${checkout}/dietela-program`;
export const nutritionistDetail = `${checkout}/nutritionist`;
export const registration = 'registration';
export const login = 'login';
......
export { default as useApi } from './useApi';
export { default as useAuthEffect } from './useAuthEffect';
export { default as useAuthGuardEffect } from './useAuthGuardEffect';
export { default as useForm } from './useForm';
import { useContext, useEffect } from 'react';
import { useNavigation } from '@react-navigation/native';
import { UserContext } from 'provider';
import * as ROUTES from 'constants/routes';
const useAuthGuardEffect = () => {
const { isAuthenticated } = useContext(UserContext);
const navigation = useNavigation();
useEffect(() => {
if (!isAuthenticated) {
navigation.navigate(ROUTES.login);
}
}, [isAuthenticated, navigation]);
};
export default useAuthGuardEffect;
import React from 'react';
import { render } from '@testing-library/react-native';
import CheckoutCard from '.';
describe('CheckoutCard component', () => {
it('for program renders correctly', () => {
render(
<CheckoutCard type="program" content="yes" onReadMore={jest.fn()} />,
);
});
it('for nutritionist renders correctly', () => {
render(
<CheckoutCard type="nutritionist" content="yes" onReadMore={jest.fn()} />,
);
});
});
import React, { FC } from 'react';
import { View } from 'react-native';
import { Text, Button } from 'react-native-elements';
import { Props } from './types';
import { styles } from './styles';
import { typographyStyles, colors } from 'styles';
const CheckoutCard: FC<Props> = ({ content, type, onReadMore }) => (
<View style={styles.container}>
<Text style={typographyStyles.overlineBig}>
Pilihan {type === 'program' ? 'Program' : 'Nutrisionis'} Anda
</Text>
<Text style={styles.title}>
{type === 'program' ? '📝 ' : '👩🏻‍⚕️ '}
{content}
</Text>
<Button
title="Baca selengkapnya"
type="clear"
icon={{
name: 'arrow-forward',
size: 22,
color: colors.primary,
}}
onPress={onReadMore}
iconRight
titleStyle={styles.readMore}
buttonStyle={styles.buttonStyle}
/>
</View>
);
export default CheckoutCard;
import { StyleSheet } from 'react-native';
import { colors, typography } from 'styles';
export const styles = StyleSheet.create({
container: {
borderWidth: 2,
borderRadius: 6,
padding: 20,
paddingBottom: 15,
marginTop: 20,
backgroundColor: colors.primaryYellow,
borderColor: colors.primaryVariant,
},
title: {
...typography.headingMedium,
marginVertical: 15,
},
readMore: {
color: colors.primary,
},
buttonStyle: {
margin: 0,
padding: 0,
},
});
export interface Props {
type: 'program' | 'nutritionist';
content: string;
onReadMore: () => void;
}
export { default as CheckoutCard } from './CheckoutCard';
import React from 'react';
import { withAuthRender, waitFor, fireEvent } from 'utils/testing';
import axios from 'axios';
import Checkout from '.';
import * as ROUTES from 'constants/routes';
import CACHE_KEYS from 'constants/cacheKeys';
import { DietelaProgram } from 'services/dietelaQuiz/quizResult';
import { setCache } from 'utils/cache';
import { mockProgramRecommendations } from '__mocks__/quizResult';
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;
describe('Checkout', () => {
const nutritionist = {
id: 1,
full_name_and_degree: 'Wendy',
registration_certificate_no: '123',
university: 'UI',
mastered_nutritional_problems: 'diet',
handled_age_group: '18',
another_practice_place: '-',
languages: 'English',
};
const retrieveCartApi = () =>
Promise.resolve({
status: 200,
data: {
id: 1,
program: {
id: 1,
unique_code: DietelaProgram.TRIAL,
},
nutritionist,
},
});
it('redirects to program detail page when user clicks Baca selengkapnya button for program', async () => {
await setCache(CACHE_KEYS.cartId, 1);
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getAllByText, getByText } = withAuthRender(
<Checkout />,
ROUTES.checkout,
);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const chosenProgram = getByText(/One Week Trial/i);
expect(chosenProgram).toBeTruthy();
const readMoreButton = getAllByText(/Baca selengkapnya/i)[0];
expect(readMoreButton).toBeTruthy();
fireEvent.press(readMoreButton);
const programDetailPage = getByText(/Program Dietela/i);
expect(programDetailPage).toBeTruthy();
});
it('redirects to nutritionist detail page when user clicks Baca selengkapnya button for nutritionist', async () => {
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getAllByText, getByText } = withAuthRender(
<Checkout />,
ROUTES.checkout,
);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const chosenNutritionist = getByText(/Wendy/i);
expect(chosenNutritionist).toBeTruthy();
const readMoreButton = getAllByText(/Baca selengkapnya/i)[1];
expect(readMoreButton).toBeTruthy();
fireEvent.press(readMoreButton);
const nutritionistDetailPage = getByText(/Coming Soon/i);
expect(nutritionistDetailPage).toBeTruthy();
});
it('redirects to choose plan page when user clicks Ganti Pilihan button', async () => {
await setCache(
CACHE_KEYS.programRecommendations,
JSON.stringify(mockProgramRecommendations),
);
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getByText } = withAuthRender(<Checkout />, ROUTES.checkout);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const changePlanButton = getByText(/ganti pilihan/i);
expect(changePlanButton).toBeTruthy();
await waitFor(() => fireEvent.press(changePlanButton));
const choosePlanPage = getByText(/Choose Plan/i);
expect(choosePlanPage).toBeTruthy();
});
afterAll(() => {
jest.clearAllMocks();
});
});
import React, { FC, useCallback } from 'react';
import { View } from 'react-native';
import { Text, Button } from 'react-native-elements';
import { useNavigation } from '@react-navigation/native';
import { Loader, BigButton } from 'components/core';
import { Section } from 'components/layout';
import CACHE_KEYS from 'constants/cacheKeys';
import * as ROUTES from 'constants/routes';
import { dietPrograms } from 'constants/dietelaProgram';
import { useApi, useAuthGuardEffect } from 'hooks';
import { retrieveCartApi } from 'services/payment';
import { getCache } from 'utils/cache';
import { typographyStyles } from 'styles';
import { styles } from './styles';
import { CheckoutCard } from './components';
const Checkout: FC = () => {
const navigation = useNavigation();
const fetchCart = useCallback(async () => {
const cartId = await getCache(CACHE_KEYS.cartId);
return await retrieveCartApi(cartId);
}, []);
const { isLoading, data } = useApi(fetchCart);
useAuthGuardEffect();
if (isLoading) {
return <Loader />;
}
return (
<View style={styles.container}>
<View>
<CheckoutCard
content={data ? dietPrograms[data.program.unique_code].title : '-'}
type="program"
onReadMore={() =>
navigation.navigate(ROUTES.programDetail, {
id: data?.program.unique_code,
})
}
/>
<CheckoutCard
content={data ? data.nutritionist.full_name_and_degree : '-'}
type="nutritionist"
onReadMore={() =>
navigation.navigate(ROUTES.nutritionistDetail, {
id: data?.nutritionist.id,
})
}
/>
</View>
<View style={styles.priceContainer}>
<Text style={typographyStyles.headingMedium}>Harga:</Text>
{data ? (
<View style={styles.currencyContainer}>
<Text style={styles.currency}>Rp</Text>
<Text style={styles.basePrice}>
{dietPrograms[data.program.unique_code].price}
</Text>
</View>
) : (
<Text>-</Text>
)}
</View>
<View>
<Button
title="ganti pilihan"
type="outline"
onPress={() => navigation.navigate(ROUTES.choosePlan)}
buttonStyle={styles.buttonStyle}
titleStyle={[typographyStyles.overlineBig, styles.titleStyle]}
/>
<Section>
<BigButton title="bayar dengan midtrans" onPress={console.log} />
</Section>
</View>
</View>
);
};
export default Checkout;
import { StyleSheet } from 'react-native';
import { layoutStyles, typography, colors } from 'styles';
export const styles = StyleSheet.create({
container: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flexGrow: 1,
...layoutStyles,
marginBottom: 10,
},
topContainer: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
},
priceContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
currencyContainer: {
display: 'flex',
flexDirection: 'row',
},
currency: {
...typography.bodyMedium,
color: colors.formLabel,
marginRight: 8,
marginTop: 8,
},
basePrice: {
...typography.displayMediumMontserrat,
color: colors.primary,
},
buttonStyle: {
borderColor: colors.buttonYellow,
borderWidth: 1,
},
titleStyle: {
color: colors.textBlack,
},
});
......@@ -59,7 +59,7 @@ const PricingCard: FC<Props> = ({
title="Baca selengkapnya"
type="clear"
icon={{
name: 'keyboard-arrow-right',
name: 'arrow-forward',
size: 25,
color: colors.primaryVariant,
}}
......
......@@ -2,11 +2,13 @@ import React from 'react';
import { render, fireEvent, waitFor } from 'utils/testing';
import axios from 'axios';
import * as ROUTES from 'constants/routes';
import ChoosePlan from '.';
import * as ROUTES from 'constants/routes';
import CACHE_KEYS from 'constants/cacheKeys';
import { mockProgramRecommendations } from '__mocks__/quizResult';
import { dietPrograms } from 'constants/dietelaProgram';
import { defaultProgramRecommendations } from 'constants/dietelaProgram';
import { setCache } from 'utils/cache';
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;
......@@ -30,7 +32,7 @@ describe('ChoosePlan', () => {
data: nutritionists,
});
it('provides default program recommendations if not passed', async () => {
it('provides default program recommendations if not in cache', async () => {
mockAxios.request.mockImplementationOnce(retrieveNutritionistsApi);
const { getAllByText } = render(<ChoosePlan />, ROUTES.choosePlan);
......@@ -41,6 +43,10 @@ describe('ChoosePlan', () => {
});
it('redirects to cart page when user clicks Bayar button', async () => {
await setCache(
CACHE_KEYS.programRecommendations,
JSON.stringify(mockProgramRecommendations),
);
const createCartApi = () =>
Promise.resolve({
status: 200,
......@@ -53,10 +59,9 @@ describe('ChoosePlan', () => {
mockAxios.request.mockImplementationOnce(retrieveNutritionistsApi);
const { getByText, queryByText } = render(
const { getByText, queryAllByText } = render(
<ChoosePlan />,
ROUTES.choosePlan,
defaultProgramRecommendations,
);
await waitFor(() => expect(mockAxios.request).toBeCalled());
......@@ -78,15 +83,17 @@ describe('ChoosePlan', () => {
expect(payButton).toBeTruthy();
await waitFor(() => fireEvent.press(payButton));
const cartPage = queryByText(/Coming Soon/i);
expect(cartPage).toBeTruthy();
const checkoutPage = queryAllByText(/Checkout/i);
expect(checkoutPage).toBeTruthy();
});
it('does not redirect to cart page when submit fails', async () => {
const createCartApi = () =>
Promise.reject({
status: 400,
data: 'error',
response: {
data: 'error',
},
});
mockAxios.request.mockImplementationOnce(retrieveNutritionistsApi);
......@@ -94,7 +101,6 @@ describe('ChoosePlan', () => {
const { getByText, queryByText } = render(
<ChoosePlan />,
ROUTES.choosePlan,
defaultProgramRecommendations,
);
await waitFor(() => expect(mockAxios.request).toBeCalled());
......@@ -116,8 +122,8 @@ describe('ChoosePlan', () => {
expect(payButton).toBeTruthy();
await waitFor(() => fireEvent.press(payButton));
const cartPage = queryByText(/Coming Soon/i);
expect(cartPage).toBeFalsy();
const checkoutPage = queryByText(/Checkout/i);
expect(checkoutPage).toBeFalsy();
});
it('redirects to program detail page when user clicks Baca selengkapnya button for program', async () => {
......@@ -126,7 +132,6 @@ describe('ChoosePlan', () => {
const { getAllByText, getByText } = render(
<ChoosePlan />,
ROUTES.choosePlan,
defaultProgramRecommendations,
);
await waitFor(() => expect(mockAxios.request).toBeCalled());
......@@ -147,7 +152,6 @@ describe('ChoosePlan', () => {
const { getAllByText, getByText } = render(
<ChoosePlan />,
ROUTES.choosePlan,
defaultProgramRecommendations,
);
await waitFor(() => expect(mockAxios.request).toBeCalled());
......
import React, { FC, useState } from 'react';
import React, { FC, useState, useCallback, useEffect } from 'react';
import { ScrollView } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
import { WizardContainer, Loader, Toast } from 'components/core';
import CACHE_KEYS from 'constants/cacheKeys';
......@@ -12,23 +12,20 @@ import {
} from 'constants/dietelaProgram';
import { retrieveNutritionistsApi } from 'services/nutritionists';
import { Nutritionist } from 'services/nutritionists/models';
import { ProgramRecommendations } from 'services/dietelaQuiz/quizResult';
import { createCartApi } from 'services/payment';
import { layoutStyles } from 'styles';
import { setCache } from 'utils/cache';
import { setCache, getCache } from 'utils/cache';
import { PricingList } from './components';
import { initialValues, getRecommendedPrograms } from './schema';
const ChoosePlan: FC = () => {
const navigation = useNavigation();
const route = useRoute();
const programs = (route.params ??
defaultProgramRecommendations) as ProgramRecommendations;
const [currentPage, setCurrentPage] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [values, setValues] = useState(initialValues);
const [programs, setPrograms] = useState(defaultProgramRecommendations);
const handleSubmit = async () => {
setIsSubmitting(true);
......@@ -37,7 +34,7 @@ const ChoosePlan: FC = () => {
if (response.success) {
await setCache(CACHE_KEYS.cartId, response.data?.id);
navigation.navigate(ROUTES.cart, response.data);
navigation.navigate(ROUTES.checkout, response.data);
} else {
Toast.show({
type: 'error',
......@@ -59,6 +56,19 @@ const ChoosePlan: FC = () => {
return values[fieldName] === null;
};
const getPrograms = useCallback(async () => {
const cachedPrograms = await getCache(CACHE_KEYS.programRecommendations);
setPrograms(
cachedPrograms
? JSON.parse(cachedPrograms)
: defaultProgramRecommendations,
);
}, []);
useEffect(() => {
getPrograms();
}, [getPrograms]);
const { isLoading, data: nutritionists = [] } = useApi(
retrieveNutritionistsApi,
);
......
......@@ -7,5 +7,6 @@ export { default as ManualRegistrationPage } from './common/ManualRegistrationPa
export { default as AllAccessQuestionnaire } from './questionnaire/AllAccessQuestionnaire';
export { default as DietelaQuizResult } from './questionnaire/DietelaQuizResult';
export { default as Checkout } from './cart/Checkout';
export { default as ChoosePlan } from './cart/ChoosePlan';
export { default as ProgramDetail } from './cart/ProgramDetail';
......@@ -91,7 +91,9 @@ describe('AllAccessQuestionnaire', () => {
const createDietProfileApi = () =>
Promise.reject({
status: 400,
data: 'error',
response: {
data: 'error',
},
});