diff --git a/locales/en/index.yml b/locales/en/index.yml index d945dc55b2b..c52887d354a 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -1393,6 +1393,7 @@ bonus: validBetween: You will be able to use the Bonus Vacanze starting from {{from}} until {{to}} bonusRejected: Attention! This Bonus Vacanze has been rejected redeemed: This Bonus Vacanze has been consumed on {{date}} + multipleBonus: Two bonuses requested and activated by you. Make sure you use the correct DSU to prevent the bonus user from incurring subsequent recovery actions. usableAmount: Expendable at the accommodation facility taxBenefit: Deductible in tax return request: Request the @@ -1483,7 +1484,7 @@ bonus: title: Cancel body: Do you really want to quit? To request the "Bonus Vacanze" you will have to repeat the process from the beginning. cancel: Continue - confirm: Abort request + confirm: Abort the request completed: Request aborted composition: amount: "Maximum amount" @@ -1501,6 +1502,9 @@ bonus: contextualHelp: title: The Bonus Vacanze body: Here you can see all the information related to the Bonus Vacanze, the measure established by the Decreto Rilancio to encourage tourism after the lockdown due to the Coronavirus emergency. + requestAlert: + title: Attention + content: "You have already obtained a bonus, continue the request only if your situation has changed and you have submitted a new DSU to update ISEE.\n Please note that the use of bonuses generated on the basis of incorrect DSU, determines actions to recover the benefit to the user.\n Are you sure you want to continue?" request: Request a new bonus requestLabel: Bonuses and discounts requestTitle: Request diff --git a/locales/it/index.yml b/locales/it/index.yml index 397d83698b7..08b083ebb4c 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -1425,6 +1425,7 @@ bonus: validBetween: Potrai usare questo Bonus Vacanze dal {{from}} fino al {{to}} bonusRejected: Attenzione! Questo Bonus Vacanze è stato annullato redeemed: Questo Bonus Vacanze è stato utilizzato il {{date}} + multipleBonus: Risultano due bonus richiesti e attivati da te. Assicurati di usare quello relativo alla DSU corretta, per evitare che chi utilizza il bonus incorra in successive azioni di recupero. usableAmount: Spendibile presso la struttura ricettiva taxBenefit: Detraibile in dichiarazione dei redditi request: Richiedi il @@ -1515,7 +1516,7 @@ bonus: title: Annulla body: Vuoi davvero uscire dal processo? Per richiedere il bonus vacanze dovrai ripetere il flusso dall’inizio. cancel: Continua - confirm: Annulla richiesta + confirm: Annulla la richiesta completed: Richiesta annullata composition: amount: "Importo massimo" @@ -1533,6 +1534,9 @@ bonus: contextualHelp: title: Il bonus vacanze body: Qui sono visualizzate tutte le informazioni relative al bonus vacanze, il provvedimento istituito dal Decreto Rilancio per incentivare il turismo dopo il lockdown dovuto all'emergenza Coronavirus. + requestAlert: + title: Attenzione + content: "Hai già ottenuto un bonus, continua la richiesta solo se la tua situazione si è modificata e hai presentato una nuova DSU per l’aggiornamento dell’ISEE.\n Ti informiamo che l’utilizzo di bonus generati in base a DSU non corrette, determina azioni di recupero dell’agevolazione verso l’utilizzatore.\n Sei sicuro di voler continuare?" request: Richiedi un nuovo bonus requestLabel: Bonus e sconti requestTitle: Richiesta diff --git a/ts/components/wallet/card/SectionCardComponent.tsx b/ts/components/wallet/card/SectionCardComponent.tsx index 426b1ac5a3d..3231d0cfb99 100644 --- a/ts/components/wallet/card/SectionCardComponent.tsx +++ b/ts/components/wallet/card/SectionCardComponent.tsx @@ -123,7 +123,11 @@ const SectionCardComponent: React.FunctionComponent = (props: Props) => { {isNew && ( - + {I18n.t("wallet.methods.newCome")} diff --git a/ts/features/bonusVacanze/screens/ActiveBonusScreen.tsx b/ts/features/bonusVacanze/screens/ActiveBonusScreen.tsx index b5210b0a506..1c23188fbc0 100644 --- a/ts/features/bonusVacanze/screens/ActiveBonusScreen.tsx +++ b/ts/features/bonusVacanze/screens/ActiveBonusScreen.tsx @@ -41,7 +41,10 @@ import { cancelLoadBonusFromIdPolling, startLoadBonusFromIdPolling } from "../store/actions/bonusVacanze"; -import { bonusActiveDetailByIdSelector } from "../store/reducers/allActive"; +import { + bonusActiveDetailByIdSelector, + ownedActiveBonus +} from "../store/reducers/allActive"; import { availableBonusTypesSelectorFromId, bonusVacanzeLogo @@ -52,6 +55,7 @@ import { isBonusActive, validityInterval } from "../utils/bonus"; +import { ActivateBonusDiscrepancies } from "./activation/request/ActivateBonusDiscrepancies"; type QRCodeContents = { [key: string]: string; @@ -378,6 +382,16 @@ const ActiveBonusScreen: React.FunctionComponent = (props: Props) => { {switchInformationText()} + + {props.hasMoreOwnedActiveBonus && ( + + )} + = (props: Props) => { const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { const bonusFromNav = ownProps.navigation.getParam("bonus"); const bonus = bonusActiveDetailByIdSelector(bonusFromNav.id)(state); + return { + hasMoreOwnedActiveBonus: ownedActiveBonus(state).length > 1, bonusInfo: availableBonusTypesSelectorFromId(ID_BONUS_VACANZE_TYPE)(state), bonus, isError: pot.isNone(bonus) && pot.isError(bonus), // error and no bonus data, user should retry to load diff --git a/ts/features/bonusVacanze/screens/BonusInformationScreen.tsx b/ts/features/bonusVacanze/screens/BonusInformationScreen.tsx index e823117c4a5..e034b290710 100644 --- a/ts/features/bonusVacanze/screens/BonusInformationScreen.tsx +++ b/ts/features/bonusVacanze/screens/BonusInformationScreen.tsx @@ -20,12 +20,15 @@ import { LightModalContextInterface } from "../../../components/ui/LightModal"; import Markdown from "../../../components/ui/Markdown"; import I18n from "../../../i18n"; import { navigateBack } from "../../../store/actions/navigation"; +import { GlobalState } from "../../../store/reducers/types"; import customVariables from "../../../theme/variables"; import { getLocalePrimaryWithFallback } from "../../../utils/locale"; import { maybeNotNullyString } from "../../../utils/strings"; +import { actionWithAlert } from "../components/alert/ActionWithAlert"; import { bonusVacanzeStyle } from "../components/Styles"; import TosBonusComponent from "../components/TosBonusComponent"; import { checkBonusVacanzeEligibility } from "../store/actions/bonusVacanze"; +import { ownedActiveBonus } from "../store/reducers/allActive"; type NavigationParams = Readonly<{ bonusItem: BonusAvailable; @@ -35,6 +38,7 @@ type OwnProps = NavigationInjectedProps; type Props = OwnProps & LightModalContextInterface & + ReturnType & ReturnType; const CSS_STYLE = ` @@ -109,6 +113,18 @@ const BonusInformationScreen: React.FunctionComponent = props => { const bonusTypeLocalizedContent: BonusAvailableContent = bonusType[getLocalePrimaryWithFallback()]; + // if the current profile owns other active bonus, show an alert informing about that + const handleBonusRequestOnPress = () => + props.hasOwnedActiveBonus + ? actionWithAlert({ + title: I18n.t("bonus.bonusInformation.requestAlert.title"), + body: I18n.t("bonus.bonusInformation.requestAlert.content"), + confirmText: I18n.t("bonus.bonusVacanze.abort.cancel"), + cancelText: I18n.t("bonus.bonusVacanze.abort.confirm"), + onConfirmAction: props.requestBonusActivation + }) + : props.requestBonusActivation(); + const cancelButtonProps = { block: true, light: true, @@ -119,7 +135,7 @@ const BonusInformationScreen: React.FunctionComponent = props => { const requestButtonProps = { block: true, primary: true, - onPress: props.requestBonusActivation, + onPress: handleBonusRequestOnPress, title: `${I18n.t("bonus.bonusVacanze.cta.requestBonus")} ${ bonusTypeLocalizedContent.name }` @@ -232,6 +248,10 @@ const BonusInformationScreen: React.FunctionComponent = props => { ); }; +const mapStateToProps = (state: GlobalState) => ({ + hasOwnedActiveBonus: ownedActiveBonus(state).length > 0 +}); + const mapDispatchToProps = (dispatch: Dispatch) => ({ requestBonusActivation: () => { dispatch(checkBonusVacanzeEligibility.request()); @@ -241,7 +261,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ export default withLightModalContext( connect( - undefined, + mapStateToProps, mapDispatchToProps )(BonusInformationScreen) ); diff --git a/ts/features/bonusVacanze/screens/activation/request/ActivateBonusDiscrepancies.tsx b/ts/features/bonusVacanze/screens/activation/request/ActivateBonusDiscrepancies.tsx index d1160518aba..8a24f59f558 100644 --- a/ts/features/bonusVacanze/screens/activation/request/ActivateBonusDiscrepancies.tsx +++ b/ts/features/bonusVacanze/screens/activation/request/ActivateBonusDiscrepancies.tsx @@ -18,9 +18,6 @@ const styles = StyleSheet.create({ }, discrepanciesBox: { backgroundColor: themeVariables.brandHighlight - }, - discrepancies: { - color: themeVariables.colorWhite } }); @@ -42,17 +39,10 @@ export const ActivateBonusDiscrepancies: React.FunctionComponent< bonusVacanzeStyle.horizontalPadding ]} > - + - - + + {`${props.attention} `} {props.text} diff --git a/ts/features/bonusVacanze/store/reducers/__tests__/allActive.test.ts b/ts/features/bonusVacanze/store/reducers/__tests__/allActive.test.ts new file mode 100644 index 00000000000..2f0a3519384 --- /dev/null +++ b/ts/features/bonusVacanze/store/reducers/__tests__/allActive.test.ts @@ -0,0 +1,103 @@ +import * as pot from "italia-ts-commons/lib/pot"; +import { + EmailString, + FiscalCode, + NonEmptyString +} from "italia-ts-commons/lib/strings"; +import { InitializedProfile } from "../../../../../../definitions/backend/InitializedProfile"; +import { Version } from "../../../../../../definitions/backend/Version"; +import { BonusActivationStatusEnum } from "../../../../../../definitions/bonus_vacanze/BonusActivationStatus"; +import { BonusCode } from "../../../../../../definitions/bonus_vacanze/BonusCode"; +import { mockedBonus } from "../../../mock/mockData"; +import { ownedActiveBonus } from "../allActive"; + +const fiscalCode = "ABCDEF83A12L719R" as FiscalCode; +const profile: InitializedProfile = { + has_profile: true, + is_inbox_enabled: true, + is_webhook_enabled: true, + is_email_enabled: true, + is_email_validated: true, + email: "test@example.com" as EmailString, + spid_email: "test@example.com" as EmailString, + family_name: "Connor", + name: "John", + fiscal_code: fiscalCode, + spid_mobile_phone: "123" as NonEmptyString, + version: 1 as Version +}; + +const potProfile = pot.some(profile); +const bonusDifferentApplicant = pot.some({ + ...mockedBonus, + status: BonusActivationStatusEnum.ACTIVE, + applicant_fiscal_code: "XXXTT83A12L7XXX" as FiscalCode +}); +const bonusActive = pot.some({ + ...mockedBonus, + status: BonusActivationStatusEnum.ACTIVE, + applicant_fiscal_code: fiscalCode +}); + +describe("ownedActiveBonus", () => { + it("should return an empty array", () => { + expect(ownedActiveBonus.resultFunc([], potProfile)).toStrictEqual([]); + }); + + it("should return an empty array", () => { + expect( + ownedActiveBonus.resultFunc( + [pot.none, pot.none, pot.noneError(new Error("some error"))], + potProfile + ) + ).toStrictEqual([]); + }); + + it("should return an empty array (different applicant)", () => { + expect( + ownedActiveBonus.resultFunc([bonusDifferentApplicant], potProfile) + ).toStrictEqual([]); + }); + + it("should return an empty array (status !== ACTIVE)", () => { + const bonusRedeemed = pot.some({ + ...mockedBonus, + status: BonusActivationStatusEnum.REDEEMED, + applicant_fiscal_code: fiscalCode + }); + expect( + ownedActiveBonus.resultFunc([bonusRedeemed], potProfile) + ).toStrictEqual([]); + }); + + it("should return the active bonus", () => { + expect( + ownedActiveBonus.resultFunc( + [bonusDifferentApplicant, bonusActive], + potProfile + ) + ).toStrictEqual([bonusActive.value]); + }); + + it("should return the active bonus", () => { + expect( + ownedActiveBonus.resultFunc( + [bonusDifferentApplicant, bonusActive, pot.none], + potProfile + ) + ).toStrictEqual([bonusActive.value]); + }); + + it("should return the active bonus(2)", () => { + const anotherBonusActive = pot.some({ + ...bonusActive.value, + id: "XYZ" as BonusCode + }); + expect( + ownedActiveBonus.resultFunc( + [bonusDifferentApplicant, bonusActive, anotherBonusActive, pot.none], + potProfile + ) + ).toStrictEqual([bonusActive.value, anotherBonusActive.value]); + }); +}); diff --git a/ts/features/bonusVacanze/store/reducers/allActive.ts b/ts/features/bonusVacanze/store/reducers/allActive.ts index 1e1018fb211..18ee5ad7a04 100644 --- a/ts/features/bonusVacanze/store/reducers/allActive.ts +++ b/ts/features/bonusVacanze/store/reducers/allActive.ts @@ -2,8 +2,13 @@ import { fromNullable } from "fp-ts/lib/Option"; import * as pot from "italia-ts-commons/lib/pot"; import { createSelector } from "reselect"; import { getType } from "typesafe-actions"; +import { BonusActivationStatusEnum } from "../../../../../definitions/bonus_vacanze/BonusActivationStatus"; import { BonusActivationWithQrCode } from "../../../../../definitions/bonus_vacanze/BonusActivationWithQrCode"; import { Action } from "../../../../store/actions/types"; +import { + profileSelector, + ProfileState +} from "../../../../store/reducers/profile"; import { GlobalState } from "../../../../store/reducers/types"; import { activateBonusVacanze, @@ -68,6 +73,33 @@ export const allBonusActiveSelector = createSelector< return Object.keys(allActiveObj).map(k => allActiveObj[k]); }); +// return the list of the active bonus of which the current profile is the applicant +export const ownedActiveBonus = createSelector< + GlobalState, + ReadonlyArray>, + ProfileState, + ReadonlyArray +>([allBonusActiveSelector, profileSelector], (allActiveArray, profile) => { + return pot.toOption(profile).fold([], p => + allActiveArray.reduce( + ( + acc: ReadonlyArray, + curr: pot.Pot + ) => { + if ( + pot.isSome(curr) && + curr.value.applicant_fiscal_code === p.fiscal_code && + curr.value.status === BonusActivationStatusEnum.ACTIVE + ) { + return [...acc, curr.value]; + } + return acc; + }, + [] + ) + ); +}); + // return the bonus from a given ID export const bonusActiveDetailByIdSelector = (id: string) => createSelector<