From 1e4cb7286d03afeb0cf140087bcf619669d30d5a Mon Sep 17 00:00:00 2001 From: Aastha Bist Date: Mon, 6 Jan 2025 15:57:42 +0530 Subject: [PATCH] feat: Add UI and controller for Opt in step (#3390) --- ...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 | 7 +- .../spender-onboarding.page.html | 8 +- .../spender-onboarding.page.ts | 3 + 7 files changed, 839 insertions(+), 3 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-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 935cc77298..888dfe0ec8 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts @@ -7,22 +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 3a602ea4c1..5807b7e12d 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.html +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.html @@ -26,11 +26,17 @@
Skip
-
+
+
+ +
diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts index 6f45327426..6d0b59842c 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts @@ -26,6 +26,8 @@ export class SpenderOnboardingPage { onboardingStep: typeof OnboardingStep = OnboardingStep; + eou: ExtendedOrgUser; + orgSettings: OrgSettings; constructor( @@ -50,6 +52,7 @@ export class SpenderOnboardingPage { ]) ), map(([eou, orgSettings, onboardingStatus, corporateCards]) => { + this.eou = eou; this.userFullName = eou.us.full_name; this.orgSettings = orgSettings; const isRtfEnabled =