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..6019980220 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.html @@ -0,0 +1,112 @@ +
+
+
Opt in to send text receipts
+
This will help you send receipts via text message.
+
+ + + +
+ +
+
+ Mobile Number + * +
+ + {{ + mobileNumberError + }} +
+
+ +
+ Enter 6 digit code sent to your phone {{ 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! +
+
+
+
+ +
+ + + Go back + + + 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..6ea9893ca6 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.scss @@ -0,0 +1,432 @@ +@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 16px; + 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; + } + + &--header, + &--content, + &--footer { + --ion-background-color: #{$grey-9}; + background: #{$grey-9}; + } + + &--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; + } + + &--container { + margin-top: 10vh; + padding: 16px 16px 36px; + } + + &--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: 600; + line-height: 1.3; + color: $blue-black; + width: 100%; + padding: 6px 0; + border-bottom: 1px solid $grey-lighter; + font-size: 20px; + + &__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..d6c83447a5 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-opt-in-step/spender-onboarding-opt-in-step.component.ts @@ -0,0 +1,319 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormControl, + FormGroup, + ValidationErrors, + Validators, +} from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ModalController, PopoverController } from '@ionic/angular'; +import { NgOtpInputComponent, NgOtpInputConfig } from 'ng-otp-input'; +import { catchError, concatMap, finalize, from, map, noop, of, Subscription, switchMap, tap } 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 { statementUploadedCard, visaRTFCard } from 'src/app/core/mock-data/platform-corporate-card.data'; +import { ExtendedOrgUser } from 'src/app/core/models/extended-org-user.model'; +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'; +import { PopoverCardsList } from 'src/app/core/models/popover-cards-list.model'; +import { SnackbarProperties } from 'src/app/core/models/snackbar-properties.model'; +import { AuthService } from 'src/app/core/services/auth.service'; +import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.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 { RealTimeFeedService } from 'src/app/core/services/real-time-feed.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 { PopupAlertComponent } from 'src/app/shared/components/popup-alert/popup-alert.component'; +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.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..82d2d02f35 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts @@ -7,6 +7,7 @@ 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'; @NgModule({ imports: [ @@ -19,6 +20,6 @@ import { SpenderOnboardingConnectCardStepComponent } from './spender-onboarding- FormsModule, ReactiveFormsModule, ], - 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 6a79858960..6edfc287d3 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.html +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.html @@ -25,11 +25,17 @@
Skip
-
+
+
+ +