diff --git a/locales/en/index.yml b/locales/en/index.yml index 23d8a154ae7..0b3426af6b0 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -2792,10 +2792,12 @@ bonus: static: Vai al codice sconto bucket: Vai al codice sconto landingpage: Vai all'opportunità + createNew: Usa un nuovo codice secondaryCta: Vai all'opportunità title: Ecco il codice sconto! - expired: Codice sconto scaduto! + expired: Il codice è scaduto copyButton: Copia codice sconto + error: In questo momento non è possibile generare un codice cta: activeBonus: Activate Carta Giovani Nazionale back: Not now diff --git a/locales/it/index.yml b/locales/it/index.yml index 5518485eb90..7ae11958b50 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -2792,10 +2792,12 @@ bonus: static: Vai al codice sconto bucket: Vai al codice sconto landingpage: Vai all'opportunità + createNew: Usa un nuovo codice secondaryCta: Vai all'opportunità title: Ecco il codice sconto! - expired: Codice sconto scaduto! + expired: Il codice è scaduto copyButton: Copia codice sconto + error: In questo momento non è possibile generare un codice cta: activeBonus: Attiva Carta Giovani Nazionale back: Non ora diff --git a/ts/features/bonus/cgn/navigation/navigator.tsx b/ts/features/bonus/cgn/navigation/navigator.tsx index 19f381f3944..e5779b0d675 100644 --- a/ts/features/bonus/cgn/navigation/navigator.tsx +++ b/ts/features/bonus/cgn/navigation/navigator.tsx @@ -12,15 +12,16 @@ import CgnActivationTimeoutScreen from "../screens/activation/CgnActivationTimeo import CgnAlreadyActiveScreen from "../screens/activation/CgnAlreadyActiveScreen"; import CgnCTAStartActivationScreen from "../screens/activation/CgnCTAStartActivationScreen"; import CgnInformationScreen from "../screens/activation/CgnInformationScreen"; +import CGNDiscountExpiredScreen from "../screens/discount/CGNDiscountExpiredScreen"; +import CgnDiscountCodeScreen from "../screens/discount/CgnDiscountCodeScreen"; +import CgnDiscountDetailScreen from "../screens/discount/CgnDiscountDetailScreen"; import EycaActivationLoading from "../screens/eyca/activation/EycaActivationLoading"; import CgnMerchantDetailScreen from "../screens/merchants/CgnMerchantDetailScreen"; import CgnMerchantLandingWebview from "../screens/merchants/CgnMerchantLandingWebview"; +import { CgnMerchantSearchScreen } from "../screens/merchants/CgnMerchantSearchScreen"; import CgnMerchantsCategoriesSelectionScreen from "../screens/merchants/CgnMerchantsCategoriesSelectionScreen"; import CgnMerchantsListByCategory from "../screens/merchants/CgnMerchantsListByCategory"; import CgnMerchantsTabsScreen from "../screens/merchants/CgnMerchantsTabsScreen"; -import CgnDiscountDetailScreen from "../screens/discount/CgnDiscountDetailScreen"; -import CgnDiscountCodeScreen from "../screens/discount/CgnDiscountCodeScreen"; -import { CgnMerchantSearchScreen } from "../screens/merchants/CgnMerchantSearchScreen"; import { CgnActivationParamsList, CgnDetailsParamsList, @@ -143,6 +144,13 @@ export const CgnDetailsNavigator = () => ( presentation: "modal" }} /> + { + const navigate = useIONavigation(); + const onPress = () => navigate.pop(); + return ( + + ); +}; + +export default CGNDiscountExpiredScreen; diff --git a/ts/features/bonus/cgn/screens/discount/CgnDiscountCodeScreen.tsx b/ts/features/bonus/cgn/screens/discount/CgnDiscountCodeScreen.tsx index ed9e9a30f21..35d06547c6a 100644 --- a/ts/features/bonus/cgn/screens/discount/CgnDiscountCodeScreen.tsx +++ b/ts/features/bonus/cgn/screens/discount/CgnDiscountCodeScreen.tsx @@ -1,6 +1,5 @@ -import { Second } from "@pagopa/ts-commons/lib/units"; import { - Body, + FooterActions, H1, H2, Icon, @@ -9,19 +8,23 @@ import { useIOTheme, VSpacer } from "@pagopa/io-app-design-system"; +import { Second } from "@pagopa/ts-commons/lib/units"; import * as React from "react"; import { StyleSheet, View } from "react-native"; -import { useIOSelector } from "../../../../../store/hooks"; -import { cgnSelectedDiscountCodeSelector } from "../../store/reducers/merchants"; +import { Otp } from "../../../../../../definitions/cgn/Otp"; +import { isReady } from "../../../../../common/model/RemoteValue"; +import { OperationResultScreenContent } from "../../../../../components/screens/OperationResultScreenContent"; +import { IOScrollView } from "../../../../../components/ui/IOScrollView"; import I18n from "../../../../../i18n"; import { useIONavigation } from "../../../../../navigation/params/AppParamsList"; -import { IOScrollView } from "../../../../../components/ui/IOScrollView"; -import { FooterActions } from "../../../../../components/ui/FooterActions"; +import { useIODispatch, useIOSelector } from "../../../../../store/hooks"; import { clipboardSetStringWithFeedback } from "../../../../../utils/clipboard"; import { CgnDiscountExpireProgressBar } from "../../components/merchants/discount/CgnDiscountExpireProgressBar"; +import CGN_ROUTES from "../../navigation/routes"; +import { resetMerchantDiscountCode } from "../../store/actions/merchants"; +import { cgnGenerateOtp } from "../../store/actions/otp"; +import { cgnSelectedDiscountCodeSelector } from "../../store/reducers/merchants"; import { cgnOtpDataSelector } from "../../store/reducers/otp"; -import { isReady } from "../../../../../common/model/RemoteValue"; -import { Otp } from "../../../../../../definitions/cgn/Otp"; const getOtpTTL = (otp: Otp): Second => { const now = new Date(); @@ -42,14 +45,25 @@ const getOtpExpirationTotal = (otp: Otp): Second => ) as Second; const CgnDiscountCodeScreen = () => { + const dispatch = useIODispatch(); const discountCode = useIOSelector(cgnSelectedDiscountCodeSelector); const discountOtp = useIOSelector(cgnOtpDataSelector); const [isDiscountCodeExpired, setIsDiscountCodeExpired] = React.useState(false); - const navigation = useIONavigation(); const theme = useIOTheme(); + const generateNewDiscountCode = () => { + dispatch( + cgnGenerateOtp.request({ + onSuccess: () => null, + onError: () => + navigation.navigate(CGN_ROUTES.DETAILS.MAIN, { + screen: CGN_ROUTES.DETAILS.MERCHANTS.DISCOUNT_CODE_FAILURE + }) + }) + ); + }; const onClose = () => { navigation.pop(); }; @@ -67,6 +81,29 @@ const CgnDiscountCodeScreen = () => { } }, [discountOtp]); + if (isDiscountCodeExpired) { + // reset discount code if expired to avoid showing it again when server is down + dispatch(resetMerchantDiscountCode()); + return ( + + ); + } + if (discountCode) { return ( <> @@ -78,7 +115,8 @@ const CgnDiscountCodeScreen = () => { firstAction: { icon: "closeLarge", accessibilityLabel: I18n.t("global.buttons.close"), - onPress: onClose + onPress: onClose, + testID: "close-button" } }} > @@ -91,12 +129,7 @@ const CgnDiscountCodeScreen = () => { -

+

{discountCode}

{isReady(discountOtp) && !isDiscountCodeExpired && ( @@ -111,14 +144,10 @@ const CgnDiscountCodeScreen = () => { /> )} - {isDiscountCodeExpired && ( - - {I18n.t(`bonus.cgn.merchantDetail.discount.expired`)} - - )} { return; } switch (merchantDetails?.discountCodeType) { - case DiscountCodeTypeEnum.landingpage: + case DiscountCodeTypeEnum.landingpage: { const landingPageUrl = discountDetails?.landingPageUrl; const referer = discountDetails?.landingPageReferrer; if (!landingPageUrl) { @@ -155,12 +155,16 @@ const CgnDiscountDetailScreen = () => { } }); break; + } case DiscountCodeTypeEnum.api: mixpanelCgnEvent("CGN_OTP_START_REQUEST"); dispatch( cgnGenerateOtp.request({ onSuccess: navigateToDiscountCode, - onError: showErrorToast + onError: () => + navigation.navigate(CGN_ROUTES.DETAILS.MAIN, { + screen: CGN_ROUTES.DETAILS.MERCHANTS.DISCOUNT_CODE_FAILURE + }) }) ); break; @@ -212,7 +216,8 @@ const CgnDiscountDetailScreen = () => { ), onPress: onPressDiscountCode, disabled: loading, - loading + loading, + testID: "discount-code-button" } : undefined; const secondaryAction: GradientBottomActions["secondaryActionProps"] = @@ -220,7 +225,8 @@ const CgnDiscountDetailScreen = () => { merchantDetails?.discountCodeType !== DiscountCodeTypeEnum.landingpage ? { label: I18n.t(`bonus.cgn.merchantDetail.discount.secondaryCta`), - onPress: onNavigateToDiscountUrl + onPress: onNavigateToDiscountUrl, + testID: "discount-url-button" } : undefined; diff --git a/ts/features/bonus/cgn/screens/discount/___tests___/CGNDiscountExpiredScreen.test.tsx b/ts/features/bonus/cgn/screens/discount/___tests___/CGNDiscountExpiredScreen.test.tsx new file mode 100644 index 00000000000..ae15fc4384a --- /dev/null +++ b/ts/features/bonus/cgn/screens/discount/___tests___/CGNDiscountExpiredScreen.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent } from "@testing-library/react-native"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../../store/actions/application"; +import { appReducer } from "../../../../../../store/reducers"; +import { renderScreenWithNavigationStoreContext } from "../../../../../../utils/testWrapper"; +import CGN_ROUTES from "../../../navigation/routes"; +import CGNDiscountExpiredScreen from "../CGNDiscountExpiredScreen"; + +const renderComponent = () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + + return renderScreenWithNavigationStoreContext( + CGNDiscountExpiredScreen, + CGN_ROUTES.DETAILS.MERCHANTS.DISCOUNT_CODE_FAILURE, + {}, + store + ); +}; + +const mockNavigate = jest.fn(); +const mockPop = jest.fn(); + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual( + "@react-navigation/native" + ), + useNavigation: () => ({ + navigate: mockNavigate, + pop: mockPop + }) +})); + +describe("CGNDiscountExpiredScreen", () => { + it("should render correctly", () => { + const component = renderComponent(); + + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("should navigate back", () => { + const component = renderComponent(); + + const back = component.getByTestId("close-button"); + fireEvent.press(back); + + expect(mockPop).toHaveBeenCalled(); + }); +}); diff --git a/ts/features/bonus/cgn/screens/discount/___tests___/CgnDiscountCodeScreen.test.tsx b/ts/features/bonus/cgn/screens/discount/___tests___/CgnDiscountCodeScreen.test.tsx new file mode 100644 index 00000000000..97453a0f51c --- /dev/null +++ b/ts/features/bonus/cgn/screens/discount/___tests___/CgnDiscountCodeScreen.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent } from "@testing-library/react-native"; +import { createStore } from "redux"; +import { OtpCode } from "../../../../../../../definitions/cgn/OtpCode"; +import { remoteReady } from "../../../../../../common/model/RemoteValue"; +import { applicationChangeState } from "../../../../../../store/actions/application"; +import { appReducer } from "../../../../../../store/reducers"; +import { GlobalState } from "../../../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../../../utils/testWrapper"; +import CGN_ROUTES from "../../../navigation/routes"; +import CgnDiscountCodeScreen from "../CgnDiscountCodeScreen"; + +const renderComponent = (state: GlobalState) => { + const store = createStore(appReducer, state as any); + + return renderScreenWithNavigationStoreContext( + CgnDiscountCodeScreen, + CGN_ROUTES.DETAILS.MERCHANTS.DISCOUNT_CODE, + {}, + store + ); +}; + +const mockNavigate = jest.fn(); +const mockPop = jest.fn(); +const mockSetOptions = jest.fn(); + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual( + "@react-navigation/native" + ), + useNavigation: () => ({ + navigate: mockNavigate, + pop: mockPop, + setOptions: mockSetOptions + }) +})); + +const globalState = appReducer(undefined, applicationChangeState("active")); + +const defaultState: GlobalState = { + ...globalState, + bonus: { + ...globalState.bonus, + cgn: { + ...globalState.bonus.cgn, + otp: { + ...globalState.bonus.cgn.otp, + data: remoteReady({ + code: "123456" as OtpCode, + expires_at: new Date(), + ttl: 100 + }) + } + } + } +}; + +describe("CgnDiscountCodeScreen", () => { + it("should render correctly", () => { + const component = renderComponent(defaultState); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("should navigate back", () => { + const component = renderComponent(defaultState); + + const back = component.getByTestId("close-button"); + fireEvent.press(back); + + expect(mockPop).toHaveBeenCalled(); + }); + + it("should generate new discount code", () => { + const state = { + ...defaultState, + bonus: { + ...defaultState.bonus, + cgn: { + ...defaultState.bonus.cgn, + otp: { + ...defaultState.bonus.cgn.otp, + data: remoteReady({ + code: "123456" as OtpCode, + expires_at: new Date(), + ttl: 0 + }) + } + } + } + }; + const component = renderComponent(state); + + const generateButton = component.getByTestId("generate-button"); + fireEvent.press(generateButton); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/features/bonus/cgn/screens/discount/___tests___/CgnDiscountDetailScreen.test.tsx b/ts/features/bonus/cgn/screens/discount/___tests___/CgnDiscountDetailScreen.test.tsx new file mode 100644 index 00000000000..e4a0c78ba67 --- /dev/null +++ b/ts/features/bonus/cgn/screens/discount/___tests___/CgnDiscountDetailScreen.test.tsx @@ -0,0 +1,160 @@ +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { fireEvent } from "@testing-library/react-native"; +import { createStore } from "redux"; +import { Discount } from "../../../../../../../definitions/cgn/merchants/Discount"; +import { DiscountCodeTypeEnum } from "../../../../../../../definitions/cgn/merchants/DiscountCodeType"; +import { Merchant } from "../../../../../../../definitions/cgn/merchants/Merchant"; +import { SupportTypeEnum } from "../../../../../../../definitions/cgn/merchants/SupportType"; +import { remoteReady } from "../../../../../../common/model/RemoteValue"; +import { applicationChangeState } from "../../../../../../store/actions/application"; +import { appReducer } from "../../../../../../store/reducers"; +import { GlobalState } from "../../../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../../../utils/testWrapper"; +import CGN_ROUTES from "../../../navigation/routes"; +import { setMerchantDiscountCode } from "../../../store/actions/merchants"; +import CgnDiscountDetailScreen from "../CgnDiscountDetailScreen"; + +const renderComponent = (state: GlobalState) => { + const store = createStore(appReducer, state as any); + + return { + component: renderScreenWithNavigationStoreContext( + CgnDiscountDetailScreen, + CGN_ROUTES.DETAILS.MERCHANTS.DISCOUNT, + {}, + store + ), + store + }; +}; + +const mockNavigate = jest.fn(); + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual( + "@react-navigation/native" + ), + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: jest.fn() + }) +})); + +const discountDetailsMock: Discount = { + id: "12345" as NonEmptyString, + name: "Test Discount" as NonEmptyString, + startDate: new Date(), + endDate: new Date(), + productCategories: [], + description: "Description of test discount" as NonEmptyString, + discount: 10, + condition: "Test condition" as NonEmptyString, + discountUrl: "https://example.com" as NonEmptyString +}; + +const mockSelectedMerchant: Merchant = { + id: "12345" as NonEmptyString, + name: "Test Merchant" as NonEmptyString, + description: "Description of test merchant" as NonEmptyString, + websiteUrl: "https://example.com" as NonEmptyString, + allNationalAddresses: false, + discounts: [], + supportType: SupportTypeEnum.EMAILADDRESS, + supportValue: "10" as NonEmptyString, + discountCodeType: DiscountCodeTypeEnum.api +}; + +const globalState = appReducer(undefined, applicationChangeState("active")); + +describe("CgnDiscountDetailScreen", () => { + it("should render correctly", () => { + const { component } = renderComponent(globalState); + + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("should navigate to discount code screen on button press", async () => { + const initialState = { + ...globalState, + bonus: { + ...globalState.bonus, + cgn: { + ...globalState.bonus.cgn, + merchants: { + ...globalState.bonus.cgn.merchants, + selectedDiscount: remoteReady(discountDetailsMock), + selectedMerchant: remoteReady(mockSelectedMerchant) + } + } + } + }; + + const { component, store } = renderComponent(initialState); + + store.dispatch(setMerchantDiscountCode("12345")); + + fireEvent.press(component.getByTestId("discount-code-button")); + + expect(mockNavigate).toHaveBeenCalledWith( + CGN_ROUTES.DETAILS.MERCHANTS.DISCOUNT_CODE + ); + }); + + it("should show footer actions if applicable", () => { + const initialState = { + ...globalState, + bonus: { + ...globalState.bonus, + cgn: { + ...globalState.bonus.cgn, + merchants: { + ...globalState.bonus.cgn.merchants, + selectedDiscount: remoteReady(discountDetailsMock), + selectedMerchant: remoteReady(mockSelectedMerchant) + } + } + } + }; + + const { component } = renderComponent(initialState); + + expect(component.getByTestId("discount-code-button")).toBeTruthy(); + expect(component.getByTestId("discount-url-button")).toBeTruthy(); + }); + + it("should navigate to landing page webview on button press", async () => { + const initialState = { + ...globalState, + bonus: { + ...globalState.bonus, + cgn: { + ...globalState.bonus.cgn, + merchants: { + ...globalState.bonus.cgn.merchants, + selectedDiscount: remoteReady({ + ...discountDetailsMock, + landingPageUrl: "https://example.com/landing" as NonEmptyString, + landingPageReferrer: "referrer" as NonEmptyString + }), + selectedMerchant: remoteReady({ + ...mockSelectedMerchant, + discountCodeType: DiscountCodeTypeEnum.landingpage + }) + } + } + } + }; + + const { component } = renderComponent(initialState); + + fireEvent.press(component.getByTestId("discount-code-button")); + + expect(mockNavigate).toHaveBeenCalledWith(CGN_ROUTES.DETAILS.MAIN, { + screen: CGN_ROUTES.DETAILS.MERCHANTS.LANDING_WEBVIEW, + params: { + landingPageUrl: "https://example.com/landing", + landingPageReferrer: "referrer" + } + }); + }); +}); diff --git a/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CGNDiscountExpiredScreen.test.tsx.snap b/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CGNDiscountExpiredScreen.test.tsx.snap new file mode 100644 index 00000000000..114ba132127 --- /dev/null +++ b/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CGNDiscountExpiredScreen.test.tsx.snap @@ -0,0 +1,730 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CGNDiscountExpiredScreen should render correctly 1`] = ` + + + + + + + + + + + + + + + CGN_MERCHANTS_DISCOUNT_CODE_FAILURE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + In questo momento non è possibile generare un codice + + + + + + + + + Close + + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CgnDiscountCodeScreen.test.tsx.snap b/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CgnDiscountCodeScreen.test.tsx.snap new file mode 100644 index 00000000000..09760fa0a90 --- /dev/null +++ b/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CgnDiscountCodeScreen.test.tsx.snap @@ -0,0 +1,827 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CgnDiscountCodeScreen should render correctly 1`] = ` + + + + + + + + + + + + + + + CGN_MERCHANTS_DISCOUNT_CODE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Il codice è scaduto + + + + + + + + + Usa un nuovo codice + + + + + + + + + + + + + Close + + + + + + + + + + + + + + + + + + + +`; diff --git a/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CgnDiscountDetailScreen.test.tsx.snap b/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CgnDiscountDetailScreen.test.tsx.snap new file mode 100644 index 00000000000..87c684845df --- /dev/null +++ b/ts/features/bonus/cgn/screens/discount/___tests___/__snapshots__/CgnDiscountDetailScreen.test.tsx.snap @@ -0,0 +1,344 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CgnDiscountDetailScreen should render correctly 1`] = ` + + + + + + + + + + + + + + + CGN_MERCHANTS_DISCOUNT_SCREEN + + + + + + + + + + + + + + + + + + + + + + + + + + +`;