From e8eb274c128bd6ea166776788af92a947fc2280a Mon Sep 17 00:00:00 2001 From: Aastha Bist Date: Mon, 6 Jan 2025 17:38:57 +0530 Subject: [PATCH] feat: Connect Card validation fixes (#3391) --- ...nboarding-connect-card-step.component.html | 131 ++++-- ...nboarding-connect-card-step.component.scss | 26 +- ...-onboarding-connect-card-step.component.ts | 109 +++-- ...nder-onboarding-opt-in-step.component.html | 99 +++++ ...nder-onboarding-opt-in-step.component.scss | 420 ++++++++++++++++++ ...r-onboarding-opt-in-step.component.spec.ts | 0 ...pender-onboarding-opt-in-step.component.ts | 305 +++++++++++++ .../spender-onboarding.module.ts | 11 +- .../spender-onboarding.page.html | 16 +- .../spender-onboarding.page.scss | 8 + .../spender-onboarding.page.ts | 13 +- 11 files changed, 1072 insertions(+), 66 deletions(-) create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.html create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.scss create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.spec.ts create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.ts diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.html b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.html index 8c31a5e04b..0ef099030a 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.html +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.html @@ -1,12 +1,11 @@ -
+
Connect corporate card
This will help you bring your card transactions into Fyle as expenses instantly.
- -
-
+ +
Corporate card @@ -14,22 +13,26 @@
- +
+ -
- {{ card?.card_number || '' }} +
+ {{ cardValuesMap[card.id].last_four || '' }} +
- Please enter a valid card number. - + Enter a valid Visa or Mastercard number. If you have other cards, please contact your admin. @@ -74,7 +77,7 @@ Enter a valid Visa number. If you have other cards, please contact your admin. - Enter a valid Mastercard number. If you have other cards, please contact your admin. @@ -91,14 +94,90 @@
-
+
+
+ Corporate card +
+ +
+ + + + + + + +
+ +
+ +
+ Please enter a valid card number. + + + Enter a valid Visa or Mastercard number. If you have other cards, please contact your admin. + + + + Enter a valid Visa number. If you have other cards, please contact your admin. + + + + + Enter a valid Mastercard number. If you have other cards, please contact your admin. + + +
+
diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss index 360329bd83..b20f990794 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss @@ -18,13 +18,36 @@ } &__card-number-input { - width: fit-content !important; margin-right: 24px; + font-size: 18px; + font-weight: 500; &::placeholder { + font-size: 14px; + font-weight: 400; word-spacing: 24px; } } + &__card-number-input-singular { + border: 0; + font-size: 18px; + font-weight: 500; + &::placeholder { + font-size: 14px; + font-weight: 400; + } + } + + &__card-number-input-container { + display: flex; + justify-content: space-between; + } + + &__card-last-four { + font-size: 18px; + font-weight: 500; + } + &__heading { color: $black; font-size: 20px; @@ -91,6 +114,7 @@ &__input-inner-container { display: flex; align-items: center; + justify-content: space-between; border-bottom: 1px solid $grey; padding-bottom: 6px; margin-bottom: 2px; diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.ts b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.ts index 76b1973e96..a1b6b9c4e7 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.ts @@ -1,4 +1,12 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; import { AbstractControl, FormArray, @@ -9,9 +17,8 @@ import { Validators, } from '@angular/forms'; import { PopoverController } from '@ionic/angular'; -import { catchError, concatMap, finalize, from, map, noop, of, switchMap, tap } from 'rxjs'; +import { catchError, concatMap, from, map, noop, of, switchMap, tap } from 'rxjs'; import { CardNetworkType } from 'src/app/core/enums/card-network-type'; -import { statementUploadedCard, visaRTFCard } from 'src/app/core/mock-data/platform-corporate-card.data'; import { OrgSettings } from 'src/app/core/models/org-settings.model'; import { OverlayResponse } from 'src/app/core/models/overlay-response.modal'; import { PlatformCorporateCard } from 'src/app/core/models/platform/platform-corporate-card.model'; @@ -25,7 +32,6 @@ import { PopupAlertComponent } from 'src/app/shared/components/popup-alert/popup templateUrl: './spender-onboarding-connect-card-step.component.html', styleUrls: ['./spender-onboarding-connect-card-step.component.scss'], }) - export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChanges { @Input() readOnly?: boolean = false; @@ -41,12 +47,19 @@ export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChan cardType = CardNetworkType; - enrollableCards: PlatformCorporateCard[]; + enrollableCards: PlatformCorporateCard[] = []; - cardValuesMap: Record = {}; + cardValuesMap: Record = {}; rtfCardType: CardNetworkType; + cardsLoading = true; + + singleEnrollableCardDetails: { card_type: string; card_number: string } = { + card_type: '', + card_number: '', + }; + cardsList: PopoverCardsList = { successfulCards: [], failedCards: [], @@ -66,15 +79,17 @@ export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChan from(cards) .pipe( concatMap((card) => - this.realTimeFeedService.enroll(card.card_number, card.id).pipe( - map(() => { - this.cardsList.successfulCards.push(`**** ${card.card_number.slice(-4)}`); - }), - catchError(() => { - this.cardsList.failedCards.push(`**** ${card.card_number.slice(-4)}`); - return of(null); - }) - ) + this.realTimeFeedService + .enroll(this.fg.controls[`card_number_${card.id}`].value + this.cardValuesMap[card.id].last_four, card.id) + .pipe( + map(() => { + this.cardsList.successfulCards.push(`**** ${card.card_number.slice(-4)}`); + }), + catchError(() => { + this.cardsList.failedCards.push(`**** ${card.card_number.slice(-4)}`); + return of(null); + }) + ) ) ) .subscribe(() => { @@ -95,7 +110,7 @@ export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChan You can cancel and retry connecting the failed card or proceed to the next step.`; } else { return ` - We ran into an issue while processing your request for the card ${this.cardsList.failedCards + We ran into an issue while processing your request for the cards ${this.cardsList.failedCards .slice(this.cardsList.failedCards.length - 1) .join(', ')} and ${this.cardsList.failedCards.slice(-1)}. You can cancel and retry connecting the failed card or proceed to the next step.`; @@ -105,7 +120,7 @@ export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChan showErrorPopover(): void { const errorPopover = this.popoverController.create({ componentProps: { - title: 'Status summary', + title: this.cardsList.successfulCards.length > 0 ? 'Status summary' : 'Failed connecting', message: this.generateMessage(), primaryCta: { text: 'Proceed anyway', @@ -141,41 +156,67 @@ export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChan } } - ngOnInit(): void { - this.fg = this.fb.group({}); + setupForm(): void { + this.cardsLoading = true; this.corporateCreditCardExpensesService .getCorporateCards() .pipe( map((corporateCards) => { - // Filter enrollable cards this.enrollableCards = corporateCards.filter((card) => card.data_feed_source === 'STATEMENT_UPLOAD'); - // Add form controls for each enrollable card - this.enrollableCards.forEach((card, index) => { - const controlName = `card_number_${index}`; - this.cardValuesMap[card.id] = { - card_number: card.card_number, + if (this.enrollableCards.length > 0) { + this.enrollableCards.forEach((card) => { + const controlName = `card_number_${card.id}`; + this.cardValuesMap[card.id] = { + last_four: card.card_number.slice(-4), + card_type: CardNetworkType.OTHERS, + }; + this.fg.addControl( + controlName, + new FormControl('', [ + Validators.required, + Validators.maxLength(12), + this.cardNumberValidator.bind(this), + this.cardNetworkValidator.bind(this), + ]) + ); + }); + } else { + this.singleEnrollableCardDetails = { + card_number: null, card_type: CardNetworkType.OTHERS, }; this.fg.addControl( - controlName, - this.fb.control('', [ + 'card_number', + new FormControl('', [ Validators.required, - Validators.maxLength(12), + Validators.maxLength(16), this.cardNumberValidator.bind(this), this.cardNetworkValidator.bind(this), ]) ); - }); + } + this.cardsLoading = false; }) ) .subscribe(); } - onCardNumberUpdate(card: PlatformCorporateCard, inputControlName: string): void { - this.cardValuesMap[card.id].card_type = this.realTimeFeedService.getCardTypeFromNumber( - this.cardValuesMap[card.id].card_number - ); + ngOnInit(): void { + this.fg = this.fb.group({}); + this.setupForm(); + } + + onCardNumberUpdate(card?: PlatformCorporateCard): void { + if (this.enrollableCards.length > 0) { + this.cardValuesMap[card.id].card_type = this.realTimeFeedService.getCardTypeFromNumber( + this.fg.controls[`card_number_${card.id}`].value as string + ); + } else { + this.singleEnrollableCardDetails.card_type = this.realTimeFeedService.getCardTypeFromNumber( + this.fg.controls.card_number.value as string + ); + } } private cardNumberValidator(control: AbstractControl): ValidationErrors { @@ -183,7 +224,7 @@ export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChan // TODO (Angular 14 >): Remove the type casting and directly use string type for the form control const cardNumber = control.value as string; - const isValid = this.realTimeFeedService.isCardNumberValid(cardNumber); + const isValid = this.realTimeFeedService.isCardNumberValid(cardNumber.replace(/ /g, '')); const cardType = this.realTimeFeedService.getCardTypeFromNumber(cardNumber); if (cardType === CardNetworkType.VISA || cardType === CardNetworkType.MASTERCARD) { diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.html b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.html new file mode 100644 index 0000000000..b8b9bfec28 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.html @@ -0,0 +1,99 @@ +
+
+
+
Opt in to send text receipts
+
This will help you send receipts via text message.
+
+ +
+ +
+ +
+
+ Mobile number +
+ + {{ + mobileNumberError + }} +
+
+ +
+ Mobile Number +
+
+ {{ mobileNumberInputValue }} + + + +
+
+ +
+ + + Resend code in + + 0:{{ otpTimer | number : '2.0' }} + + ({{ otpAttemptsLeft }} attempts left) + + + + + + ({{ otpAttemptsLeft }} attempts left) + +
+ Resend Code + (0 attempts left) +
+
+
+
+
+ +
+ +
You are all set
+
+ We have sent you a confirmation message. You can now use text messages to create and submit your next + expense! +
+
+
+
+
+ +
+ + Continue + +
+
diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.scss b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.scss new file mode 100644 index 0000000000..585a4626e8 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.scss @@ -0,0 +1,420 @@ +@import '../../../../theme/colors.scss'; + +.opt-in-step { + position: relative; + height: 100%; + + &__body { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + } + + &__primary-cta-container { + display: flex; + flex-direction: row; + justify-content: flex-end; + } + + &__card-number-input { + width: fit-content !important; + margin-right: 24px; + &::placeholder { + word-spacing: 24px; + } + } + + &__heading { + color: $black; + font-size: 20px; + font-weight: 500; + line-height: normal; + margin-bottom: 8px; + margin-top: 32px; + } + + &__sub-heading { + color: $dark-grey; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 1.28; + margin-bottom: 32px; + } + + &__primary-cta { + width: 108px; + align-self: flex-end; + } + + &__toolbar-title { + font-size: 20px; + font-weight: 500; + color: $black; + line-height: normal; + } + + &__toolbar-close-btn { + position: absolute; + } + + &__body { + display: flex; + flex-direction: column; + } + + &__content { + --padding-start: 16px; + --padding-end: 16px; + --padding-top: 16px; + + max-height: 50vh; + } + + &__input-container { + padding: 16px 16px 8px;; + border-radius: 8px; + border: 1px solid $grey; + + &:focus-within:not(&__error) { + border: 1px solid $black-light; + } + } + + &__input-label { + font-size: 12px; + color: $black-light; + margin-bottom: 6px; + } + + &__input-inner-container { + display: flex; + align-items: center; + border-bottom: 1px solid $grey; + padding-bottom: 6px; + margin-bottom: 2px; + + &__error { + border-bottom: 1px solid $red; + } + + &:focus-within:not(&__error) { + border-bottom: 1px solid $black-light; + } + } + + &__card-number-input { + border: 0; + color: $blue-black; + width: 100%; + } + + &__input-default-icon { + width: 20px; + height: 20px; + color: $grey-light; + } + + &__input-visa-icon, + &__input-mastercard-icon { + width: 38px; + height: 22px; + } + + &__input-error-space { + width: 0px; + height: 16px; + float: right; + } + + &__input-errors { + color: $red; + font-size: 12px; + line-height: 1.3; + + & > :not(:first-child) { + // Only show one error message at a time + display: none; + } + } + + &__view-tnc-btn { + width: 100%; + background: $pink-gradient; + background-clip: text; + color: transparent; + padding: 12px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 14px; + } + + &__view-tnc-btn-icon { + width: 18px; + height: 18px; + color: $brand-primary; + } + + &__tnc { + display: flex; + flex-direction: column; + gap: 24px; + padding-top: 8px; + font-size: 14px; + } + + &__tnc-heading { + font-weight: 500; + color: $black; + } + + &__tnc-list { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__tnc-link { + color: $blue-4; + text-decoration: none; + } + + &__tnc-link-icon { + width: 16px; + height: 16px; + vertical-align: text-bottom; + } + + &__footer-toolbar { + padding: 16px; + } + + &__toolbar { + margin-top: calc(env(safe-area-inset-top)); + border: 0; + } + + &__title { + margin-right: 32px; + + &__icon { + width: 70px; + height: 24px; + } + } + + &__try-ai-text { + margin-left: 6px; + font-weight: 500; + } + + &__description { + font-size: 20px; + font-weight: 500; + margin-bottom: 48px; + line-height: 1.5; + } + + &__sparkle-icon { + width: 21px; + height: 21px; + } + + &__edit-icon { + color: $brand-primary; + margin-left: 4px; + margin-bottom: -2px; + } + + &__mobile-input-container { + &__label { + margin-right: 8px; + color: $black-light; + line-height: 1.3; + font-weight: 400; + font-size: 12px; + } + + &__mandatory { + color: $red; + } + + &__input::placeholder { + font-size: 12px; + font-weight: 400; + } + + &__input { + border: 0; + border-radius: 0; + font-weight: 500; + line-height: 1.3; + color: $blue-black; + width: 100%; + padding: 6px 0; + border-bottom: 1px solid $grey-lighter; + font-size: 18px; + + &__error { + border-bottom: 1px solid $red; + } + } + + &__input:focus { + border-bottom: 1px solid $blue-black; + } + + &__error { + color: $red; + font-size: 12px; + } + } + + &__primary-cta { + margin: 16px auto; + width: 90%; + + .mat-button-base { + width: 100%; + font-weight: 700; + min-height: 47px; + } + } + + &__otp-container { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 32px; + + &__label { + margin: 0 8px 0 0; + color: $black-light; + line-height: 1.3; + font-weight: 400; + display: flex; + justify-content: space-between; + align-items: center; + + &__attempts { + font-size: 12px; + margin-left: 8px; + } + + &__resend { + color: $brand-primary; + font-size: 14px; + font-weight: 500; + background: none; + + &__disabled { + @extend .opt-in-step__otp-container__label__resend; + opacity: 0.6; + } + } + + &__otp-timer { + font-size: 14px; + + &__timer { + color: $brand-primary; + } + } + } + + &__mandatory { + color: $red; + } + + &__input { + border: 0; + border-radius: 0; + font-weight: 400; + line-height: 1.3; + color: $blue-black; + width: 100%; + padding: 8px 0; + border-bottom: 1px solid $grey-lighter; + + &__error { + border-bottom: 1px solid $red; + } + } + + &__input:focus { + border-bottom: 1px solid $blue-black; + } + + &__error { + color: $red; + font-size: 12px; + } + + &__info-box { + margin-bottom: 24px; + } + } + + &__send-code-btn { + background: linear-gradient(162.38deg, #ff3366 3.01%, #fe5196 111.5%); + &__icon { + width: 12px; + height: 14px; + margin-left: 6px; + } + } + + &__success { + display: flex; + flex-direction: column; + justify-content: center; + height: 90%; + align-items: center; + margin-top: 24px; + + &__image-container { + width: 80px; + height: 80px; + margin-bottom: 24px; + color: $green; + } + + &__header { + font-weight: 600; + font-size: 24px; + } + + &__description { + margin-top: 16px; + width: 95%; + text-align: center; + padding: 0 24px; + } + + &__footer { + padding: 14px 16px; + gap: 12px; + + &__primary-cta-text, + &__secondary-cta-text { + font-weight: 500; + font-size: 14px; + } + } + + &__help-article-icon { + margin: 4px 0px 0px 6px; + width: 14px; + height: 14px; + } + } + + &__footer { + margin-bottom: calc(env(safe-area-inset-bottom)); + } +} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.spec.ts b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.ts b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.ts new file mode 100644 index 0000000000..28bd3e9aa1 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.ts @@ -0,0 +1,305 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ModalController } from '@ionic/angular'; +import { NgOtpInputComponent, NgOtpInputConfig } from 'ng-otp-input'; +import { finalize, from, Subscription, switchMap } from 'rxjs'; +import { CardNetworkType } from 'src/app/core/enums/card-network-type'; +import { OptInFlowState } from 'src/app/core/enums/opt-in-flow-state.enum'; +import { ToastType } from 'src/app/core/enums/toast-type.enum'; +import { ExtendedOrgUser } from 'src/app/core/models/extended-org-user.model'; +import { PlatformCorporateCard } from 'src/app/core/models/platform/platform-corporate-card.model'; +import { PopoverCardsList } from 'src/app/core/models/popover-cards-list.model'; +import { AuthService } from 'src/app/core/services/auth.service'; +import { LoaderService } from 'src/app/core/services/loader.service'; +import { MobileNumberVerificationService } from 'src/app/core/services/mobile-number-verification.service'; +import { OrgUserService } from 'src/app/core/services/org-user.service'; +import { SnackbarPropertiesService } from 'src/app/core/services/snackbar-properties.service'; +import { TrackingService } from 'src/app/core/services/tracking.service'; +import { UserEventService } from 'src/app/core/services/user-event.service'; +import { ToastMessageComponent } from 'src/app/shared/components/toast-message/toast-message.component'; + +@Component({ + selector: 'app-spender-onboarding-opt-in-step', + templateUrl: './spender-onboarding-opt-in-step.component.html', + styleUrls: ['./spender-onboarding-opt-in-step.component.scss'], +}) +export class SpenderOnboardingOptInStepComponent implements OnInit, OnChanges { + @ViewChild('mobileInput') mobileInputEl: ElementRef; + + @ViewChild(NgOtpInputComponent, { static: false }) ngOtpInput: NgOtpInputComponent; + + @Input() eou: ExtendedOrgUser; + + @Output() isStepComplete: EventEmitter = new EventEmitter(); + + cardForm: FormControl; + + isVisaRTFEnabled = false; + + isMastercardRTFEnabled = false; + + cardType = CardNetworkType; + + enrollableCards: PlatformCorporateCard[]; + + cardValuesMap: Record = {}; + + rtfCardType: CardNetworkType; + + cardsList: PopoverCardsList = { + successfulCards: [], + failedCards: [], + }; + + fg: FormGroup; + + optInFlowState: OptInFlowState = OptInFlowState.MOBILE_INPUT; + + mobileNumberInputValue: string; + + mobileNumberError: string; + + sendCodeLoading = false; + + otpTimer: number; + + showOtpTimer = false; + + otpError: string; + + disableResendOtp = false; + + otpAttemptsLeft: number; + + verifyingOtp = false; + + hardwareBackButtonAction: Subscription; + + otpConfig: NgOtpInputConfig = { + allowNumbersOnly: true, + length: 6, + inputStyles: { + width: '48px', + height: '48px', + boxShadow: '0px 0px 8px 0px rgba(44, 48, 78, 0.1)', + border: 'none', + }, + }; + + constructor( + private fb: FormBuilder, + private trackingService: TrackingService, + private modalController: ModalController, + private orgUserService: OrgUserService, + private authService: AuthService, + private mobileNumberVerificationService: MobileNumberVerificationService, + private loaderService: LoaderService, + private matSnackBar: MatSnackBar, + private userEventService: UserEventService, + private snackbarProperties: SnackbarPropertiesService + ) {} + + get OptInFlowState(): typeof OptInFlowState { + return OptInFlowState; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.eou.currentValue !== changes.eou.previousValue) { + this.mobileNumberInputValue = this.eou.ou.mobile; + } + } + + ngOnInit(): void { + this.fg = this.fb.group({}); + this.fg.addControl('mobile_number', this.fb.control('', [Validators.required, Validators.maxLength(10)])); + } + + goBack(): void { + if (this.optInFlowState === OptInFlowState.OTP_VERIFICATION) { + this.trackingService.optInFlowRetry({ + message: 'EDIT_NUMBER', + }); + this.optInFlowState = OptInFlowState.MOBILE_INPUT; + } else if (this.optInFlowState === OptInFlowState.SUCCESS) { + this.trackingService.optInFlowSuccess({ + message: 'SUCCESS', + }); + this.modalController.dismiss({ action: 'SUCCESS' }); + } else { + this.trackingService.skipOptInFlow(); + this.modalController.dismiss(); + } + } + + validateInput(): void { + if (!this.mobileNumberInputValue?.length) { + this.mobileNumberError = 'Please enter mobile number'; + } else if (!this.mobileNumberInputValue.match(/^\+1\d{10}$/)) { + this.mobileNumberError = 'Please enter a valid number with +1 country code. Try re-entering your number.'; + } + } + + saveMobileNumber(): void { + //If user has not changed the verified mobile number, close the popover + if (this.mobileNumberInputValue === this.eou.ou.mobile && this.eou.ou.mobile_verified) { + this.modalController.dismiss(); + } else { + this.validateInput(); + if (!this.mobileNumberError?.length) { + this.sendCodeLoading = true; + + const updatedOrgUserDetails = { + ...this.eou.ou, + mobile: this.mobileNumberInputValue, + }; + this.orgUserService + .postOrgUser(updatedOrgUserDetails) + .pipe(switchMap(() => this.authService.refreshEou())) + .subscribe({ + complete: () => { + this.resendOtp('INITIAL'); + }, + error: () => { + this.sendCodeLoading = false; + }, + }); + } + } + } + + resendOtp(action: 'CLICK' | 'INITIAL'): void { + this.sendCodeLoading = true; + this.mobileNumberVerificationService.sendOtp().subscribe({ + next: (otpDetails) => { + this.otpAttemptsLeft = otpDetails.attempts_left; + + if (action === 'INITIAL') { + this.optInFlowState = OptInFlowState.OTP_VERIFICATION; + } + + if (this.otpAttemptsLeft > 0) { + if (action === 'CLICK') { + this.toastWithoutCTA('Code sent successfully', ToastType.SUCCESS, 'msb-success-with-camera-icon'); + this.ngOtpInput.setValue(''); + } + this.startTimer(); + } else { + this.toastWithoutCTA( + 'You have reached the limit for 6 digit code requests. Try again after 24 hours.', + ToastType.FAILURE, + 'msb-failure-with-camera-icon' + ); + this.disableResendOtp = true; + } + + this.sendCodeLoading = false; + }, + error: (err: HttpErrorResponse) => { + if (err.status === 400) { + const error = err.error as { message: string }; + const errorMessage = error.message?.toLowerCase() || ''; + if (errorMessage.includes('out of attempts') || errorMessage.includes('max send attempts reached')) { + this.trackingService.optInFlowError({ + message: 'OTP_MAX_ATTEMPTS_REACHED', + }); + this.toastWithoutCTA( + 'You have reached the limit for 6 digit code requests. Try again after 24 hours.', + ToastType.FAILURE, + 'msb-failure-with-camera-icon' + ); + this.ngOtpInput?.setValue(''); + this.disableResendOtp = true; + } else if (errorMessage.includes('invalid parameter')) { + this.toastWithoutCTA( + 'Invalid mobile number. Please try again.', + ToastType.FAILURE, + 'msb-failure-with-camera-icon' + ); + } else if (errorMessage.includes('expired')) { + this.toastWithoutCTA( + 'The code has expired. Please request a new one.', + ToastType.FAILURE, + 'msb-failure-with-camera-icon' + ); + this.ngOtpInput?.setValue(''); + } else { + this.toastWithoutCTA('Code is invalid', ToastType.FAILURE, 'msb-failure-with-camera-icon'); + this.ngOtpInput?.setValue(''); + } + } + + this.sendCodeLoading = false; + }, + }); + } + + verifyOtp(otp: string): void { + this.verifyingOtp = true; + from(this.loaderService.showLoader('Verifying code...')) + .pipe( + switchMap(() => this.mobileNumberVerificationService.verifyOtp(otp)), + switchMap(() => this.authService.refreshEou()), + finalize(() => this.loaderService.hideLoader()) + ) + .subscribe({ + complete: () => { + this.optInFlowState = OptInFlowState.SUCCESS; + this.verifyingOtp = false; + this.isStepComplete.emit(true); + this.userEventService.clearTaskCache(); + }, + error: () => { + this.toastWithoutCTA('Code is invalid', ToastType.FAILURE, 'msb-failure-with-camera-icon'); + this.ngOtpInput.setValue(''); + this.verifyingOtp = false; + }, + }); + } + + onOtpChange(otp: string): void { + if (otp.length === 6) { + this.verifyOtp(otp); + } + } + + toastWithoutCTA(toastMessage: string, toastType: ToastType, panelClass: string): void { + const message = toastMessage; + + this.matSnackBar.openFromComponent(ToastMessageComponent, { + ...this.snackbarProperties.setSnackbarProperties(toastType, { message }), + panelClass: [panelClass], + }); + this.trackingService.showToastMessage({ ToastContent: message }); + } + + startTimer(): void { + this.otpTimer = 30; + this.showOtpTimer = true; + const interval = setInterval(() => { + this.otpTimer--; + if (this.otpTimer === 0) { + clearInterval(interval); + this.showOtpTimer = false; + } + }, 1000); + } + + onGotItClicked(): void { + this.trackingService.optInFlowSuccess({ + message: 'SUCCESS', + }); + this.modalController.dismiss({ action: 'SUCCESS' }); + } +} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts index b1b3b09128..888dfe0ec8 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts @@ -7,18 +7,25 @@ import { CommonModule } from '@angular/common'; import { SpenderOnboardingRoutingModule } from './spender-onboarding-routing.module'; import { MatButtonModule } from '@angular/material/button'; import { SpenderOnboardingConnectCardStepComponent } from './spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component'; +import { SpenderOnboardingOptInStepComponent } from './spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component'; +import { NgOtpInputModule } from 'ng-otp-input'; +import { NgxMaskModule } from 'ngx-mask'; @NgModule({ imports: [ - SharedModule, CommonModule, FormsModule, + SharedModule, IonicModule, MatButtonModule, SpenderOnboardingRoutingModule, FormsModule, ReactiveFormsModule, + NgOtpInputModule, + NgxMaskModule.forRoot({ + validation: false, + }), ], - declarations: [SpenderOnboardingPage, SpenderOnboardingConnectCardStepComponent], + declarations: [SpenderOnboardingPage, SpenderOnboardingConnectCardStepComponent, SpenderOnboardingOptInStepComponent], }) export class SpenderOnboardingPageModule {} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.html b/src/app/fyle/spender-onboarding/spender-onboarding.page.html index 0755ef2658..f2f709fd1b 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.html +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.html @@ -21,9 +21,22 @@ class="spender-onboarding__progress-bar spender-onboarding__progress-bar-right" [src]="'/assets/svg/progress-bar.svg'" slot="icon-only" + [ngClass]="{'spender-onboarding__step-next': currentStep === 'CONNECT_CARD', 'spender-onboarding__step-hide': onlyOptInEnabled}" >
-
Skip
+
Skip
+
+
+ +
+
+
-
diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.scss b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss index c79f3c07dc..e202fc0614 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.scss +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss @@ -16,6 +16,14 @@ $toolbar-border: #ababab6b; margin-left: 4px; } + &__step-next { + opacity: 0.1; + } + + &__step-hide { + display: none; + } + &__menu-icon-container { padding: 20px 16px; } diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts index 236cd20f06..6d0b59842c 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts @@ -8,6 +8,7 @@ import { SpenderOnboardingService } from 'src/app/core/services/spender-onboardi import { OrgSettingsService } from 'src/app/core/services/org-settings.service'; import { Router } from '@angular/router'; import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.service'; +import { OrgSettings } from 'src/app/core/models/org-settings.model'; @Component({ selector: 'app-spender-onboarding', @@ -19,10 +20,16 @@ export class SpenderOnboardingPage { userFullName: string; - currentStep: OnboardingStep; + currentStep: OnboardingStep = OnboardingStep.CONNECT_CARD; + + onlyOptInEnabled = false; onboardingStep: typeof OnboardingStep = OnboardingStep; + eou: ExtendedOrgUser; + + orgSettings: OrgSettings; + constructor( private loaderService: LoaderService, private orgUserService: OrgUserService, @@ -45,13 +52,16 @@ export class SpenderOnboardingPage { ]) ), map(([eou, orgSettings, onboardingStatus, corporateCards]) => { + this.eou = eou; this.userFullName = eou.us.full_name; + this.orgSettings = orgSettings; const isRtfEnabled = orgSettings.visa_enrollment_settings.enabled && orgSettings.mastercard_enrollment_settings.enabled; const isAmexFeedEnabled = orgSettings.amex_feed_enrollment_settings.enabled; const rtfCards = corporateCards.filter((card) => card.is_visa_enrolled || card.is_mastercard_enrolled); if (isAmexFeedEnabled && !isRtfEnabled) { this.currentStep = OnboardingStep.OPT_IN; + this.onlyOptInEnabled = true; } else if (isRtfEnabled) { // If Connect Card was skipped earlier or Cards are already enrolled, then go to OPT_IN step if ( @@ -60,6 +70,7 @@ export class SpenderOnboardingPage { rtfCards.length > 0 ) { this.currentStep = OnboardingStep.OPT_IN; + this.onlyOptInEnabled = true; } else { this.currentStep = OnboardingStep.CONNECT_CARD; }