From 7d9c2ed9041ff925a5bb8ad200be6379dc6fb912 Mon Sep 17 00:00:00 2001 From: aastha Date: Fri, 20 Dec 2024 11:16:40 +0530 Subject: [PATCH 01/12] Minor --- src/app/fyle/fyle-routing.module.ts | 5 + .../spender-onboarding-routing.module.ts | 16 +++ .../spender-onboarding.module.ts | 14 +++ .../spender-onboarding.page.html | 29 +++++ .../spender-onboarding.page.scss | 104 ++++++++++++++++++ .../spender-onboarding.page.spec.ts | 0 .../spender-onboarding.page.ts | 32 ++++++ src/assets/svg/progress-bar.svg | 5 + 8 files changed, 205 insertions(+) create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-routing.module.ts create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding.module.ts create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding.page.html create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding.page.scss create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding.page.ts create mode 100644 src/assets/svg/progress-bar.svg diff --git a/src/app/fyle/fyle-routing.module.ts b/src/app/fyle/fyle-routing.module.ts index fcbbe2fb45..314f8ed86e 100644 --- a/src/app/fyle/fyle-routing.module.ts +++ b/src/app/fyle/fyle-routing.module.ts @@ -45,6 +45,11 @@ const routes: Routes = [ path: 'add_edit_expense', loadChildren: () => import('./add-edit-expense/add-edit-expense.module').then((m) => m.AddEditExpensePageModule), }, + { + path: 'spender_onboarding', + loadChildren: () => + import('./spender-onboarding/spender-onboarding.module').then((m) => m.SpenderOnboardingPageModule), + }, { path: 'team_reports', loadChildren: () => import('./team-reports/team-reports.module').then((m) => m.TeamReportsPageModule), diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-routing.module.ts b/src/app/fyle/spender-onboarding/spender-onboarding-routing.module.ts new file mode 100644 index 0000000000..bfb053b216 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { SpenderOnboardingPage } from './spender-onboarding.page'; + +const routes: Routes = [ + { + path: '', + component: SpenderOnboardingPage, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class SpenderOnboardingRoutingModule {} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts new file mode 100644 index 0000000000..9f64b3b718 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { SharedModule } from 'src/app/shared/shared.module'; +import { SpenderOnboardingPage } from './spender-onboarding.page'; +import { IonicModule } from '@ionic/angular'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { SpenderOnboardingRoutingModule } from './spender-onboarding-routing.module'; +import { MatButtonModule } from '@angular/material/button'; + +@NgModule({ + imports: [SharedModule, CommonModule, FormsModule, IonicModule, MatButtonModule, SpenderOnboardingRoutingModule], + declarations: [SpenderOnboardingPage], +}) +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 new file mode 100644 index 0000000000..6127034901 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.html @@ -0,0 +1,29 @@ +
+ + + +
+
👋
+
+
Hello {{userFullName}},
+
Let’s get started!
+
+
+
+
+
+ + +
+
Skip
+
+
+
diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.scss b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss new file mode 100644 index 0000000000..5c241bf2f7 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss @@ -0,0 +1,104 @@ +@import '../../../theme/colors.scss'; +$item-border-color: #e0e0e0; +$background-color: #fff; +$menu-botton-color: #000; +$toolbar-border: #ababab6b; + +.spender-onboarding { + background: $border-info-lighter; + height: 100%; + + &__progress-bar { + width: 50px; + } + + &__progress-bar-right { + margin-left: 4px; + } + + &__menu-icon-container { + padding: 20px 16px; + } + + &__step-tracker { + padding: 24px; + border-radius: 32px 32px 0px 0px; + border: 1px solid $border-info-lighter; + background: $pure-white; + box-shadow: -14px -6px 24px 0px #00000008; + } + + &__stepper-container { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__user-greeting { + display: flex; + gap: 12px; + align-items: flex-start; + color: $dark-grey; + line-height: 1.4; + padding: 0 24px; + margin: 12px 0 32px 0; + height: 42px; + } + + &__wave { + font-size: 32px; + align-self: center; + } + + &__user-name { + font-size: 16px; + font-weight: 500; + } + + &__content { + font-size: 14px; + font-weight: 400; + } + + &__menubutton { + color: $menu-botton-color; + } + + &__title { + line-height: 1.3; + color: $menu-botton-color; + } + + &__zero-state { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + &__option { + font-weight: normal; + color: #0077ee; + font-size: 16px; + } + + &__select-state { + display: flex; + justify-content: flex-start; + align-items: center; + min-width: 120px; + } + + &__select-state-checkbox { + margin-right: 8px; + } + + &__skip-cta { + color: $blue-black; + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 1.3; + } +} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts new file mode 100644 index 0000000000..4b5e848f05 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts @@ -0,0 +1,32 @@ +import { Component } from '@angular/core'; +import { from, map, switchMap } from 'rxjs'; +import { ExtendedOrgUser } from 'src/app/core/models/extended-org-user.model'; +import { LoaderService } from 'src/app/core/services/loader.service'; +import { OrgUserService } from 'src/app/core/services/org-user.service'; + +@Component({ + selector: 'app-spender-onboarding', + templateUrl: './spender-onboarding.page.html', + styleUrls: ['./spender-onboarding.page.scss'], +}) +export class SpenderOnboardingPage { + isLoading = true; + + userFullName: string; + + constructor(private loaderService: LoaderService, private orgUserService: OrgUserService) {} + + ionViewWillEnter(): void { + this.isLoading = true; + from(this.loaderService.showLoader()) + .pipe( + switchMap(() => this.orgUserService.getCurrent()), + map((eou: ExtendedOrgUser) => { + this.userFullName = eou.us.full_name; + this.isLoading = false; + this.loaderService.hideLoader(); + }) + ) + .subscribe(); + } +} diff --git a/src/assets/svg/progress-bar.svg b/src/assets/svg/progress-bar.svg new file mode 100644 index 0000000000..97a83d3ca9 --- /dev/null +++ b/src/assets/svg/progress-bar.svg @@ -0,0 +1,5 @@ + + + + + From e6b4d99e039b4f37b49d93c8f3321223b7a8cd08 Mon Sep 17 00:00:00 2001 From: aastha Date: Fri, 20 Dec 2024 14:14:00 +0530 Subject: [PATCH 02/12] Minor --- .../spender-onboarding.service.spec.ts | 107 ++++++++++++++++++ .../services/spender-onboarding.service.ts | 2 +- .../models/onboarding-step.enum.ts | 4 + .../spender-onboarding.page.ts | 37 +++++- 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/app/fyle/spender-onboarding/models/onboarding-step.enum.ts diff --git a/src/app/core/services/spender-onboarding.service.spec.ts b/src/app/core/services/spender-onboarding.service.spec.ts index e69de29bb2..d2cb0c6408 100644 --- a/src/app/core/services/spender-onboarding.service.spec.ts +++ b/src/app/core/services/spender-onboarding.service.spec.ts @@ -0,0 +1,107 @@ +import { TestBed } from '@angular/core/testing'; +import { SpenderOnboardingService } from './spender-onboarding.service'; +import { SpenderPlatformV1ApiService } from './spender-platform-v1-api.service'; +import { OnboardingStepStatus } from '../models/onboarding-step-status.model'; +import { of } from 'rxjs'; + +// Add any other imports as necessary from the original TasksService spec + +describe('SpenderOnboardingService', () => { + let spenderOnboardingService: SpenderOnboardingService; + let spenderPlatformV1ApiService: jasmine.SpyObj; + + beforeEach(() => { + const spenderPlatformV1ApiServiceSpy = jasmine.createSpyObj('SpenderPlatformV1ApiService', ['get', 'post']); + TestBed.configureTestingModule({ + providers: [ + SpenderOnboardingService, + [ + { + provide: SpenderPlatformV1ApiService, + useValue: spenderPlatformV1ApiServiceSpy, + }, + ], + ], + }); + spenderOnboardingService = TestBed.inject(SpenderOnboardingService); + spenderPlatformV1ApiService = TestBed.inject( + SpenderPlatformV1ApiService + ) as jasmine.SpyObj; + }); + + fit('getOnboardingStatus(): should get onboarding status', (done) => { + const onboardingResponse: OnboardingStepStatus = { + is_configured: true, + is_skipped: false, + }; + spenderPlatformV1ApiService.get.and.returnValue(of({ data: onboardingResponse })); + + spenderOnboardingService.getOnboardingStatus().subscribe((res) => { + expect(res).toEqual(onboardingResponse); + expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/spender/onboarding'); + done(); + }); + }); + + fit('processConnnectCardsStep(): should process connect card step', (done) => { + const onboardingRequestResponse: OnboardingStepStatus = { + is_configured: true, + is_skipped: false, + }; + spenderPlatformV1ApiService.post.and.returnValue(of({ data: onboardingRequestResponse })); + + spenderOnboardingService.processConnectCardsStep(onboardingRequestResponse).subscribe((res) => { + expect(res).toEqual(onboardingRequestResponse); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith( + '/spender/onboarding/process_step_connect_cards', + onboardingRequestResponse + ); + done(); + }); + }); + + fit('processWelcomeModalStep(): should get category count', (done) => { + const onboardingRequestResponse: OnboardingStepStatus = { + is_configured: true, + is_skipped: false, + }; + spenderPlatformV1ApiService.post.and.returnValue(of({ data: onboardingRequestResponse })); + + spenderOnboardingService.processConnectCardsStep(onboardingRequestResponse).subscribe((res) => { + expect(res).toEqual(onboardingRequestResponse); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith( + '/spender/onboarding/process_step_connect_cards', + onboardingRequestResponse + ); + done(); + }); + }); + + fit('getActiveCategoriesCount(): should get category count', (done) => { + const onboardingResponse: OnboardingStepStatus = { + is_configured: true, + is_skipped: false, + }; + spenderPlatformV1ApiService.get.and.returnValue(of({ data: onboardingResponse })); + + spenderOnboardingService.getOnboardingStatus().subscribe((res) => { + expect(res).toEqual(onboardingResponse); + expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/spender/onboarding'); + done(); + }); + }); + + fit('getActiveCategoriesCount(): should get category count', (done) => { + const onboardingResponse: OnboardingStepStatus = { + is_configured: true, + is_skipped: false, + }; + spenderPlatformV1ApiService.get.and.returnValue(of({ data: onboardingResponse })); + + spenderOnboardingService.getOnboardingStatus().subscribe((res) => { + expect(res).toEqual(onboardingResponse); + expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/spender/onboarding'); + done(); + }); + }); +}); diff --git a/src/app/core/services/spender-onboarding.service.ts b/src/app/core/services/spender-onboarding.service.ts index 60a83ce27e..323ca0e4bd 100644 --- a/src/app/core/services/spender-onboarding.service.ts +++ b/src/app/core/services/spender-onboarding.service.ts @@ -8,7 +8,7 @@ import { OnboardingStepStatus } from '../models/onboarding-step-status.model'; @Injectable({ providedIn: 'root', }) -export class OnboardingService { +export class SpenderOnboardingService { constructor(private spenderPlatformV1ApiService: SpenderPlatformV1ApiService) {} getOnboardingStatus(): Observable { diff --git a/src/app/fyle/spender-onboarding/models/onboarding-step.enum.ts b/src/app/fyle/spender-onboarding/models/onboarding-step.enum.ts new file mode 100644 index 0000000000..45b3a27da1 --- /dev/null +++ b/src/app/fyle/spender-onboarding/models/onboarding-step.enum.ts @@ -0,0 +1,4 @@ +export enum OnboardingStep { + CONNECT_CARD, + OPT_IN, +} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts index 4b5e848f05..96aa26b24d 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts @@ -3,6 +3,10 @@ import { from, map, switchMap } from 'rxjs'; import { ExtendedOrgUser } from 'src/app/core/models/extended-org-user.model'; import { LoaderService } from 'src/app/core/services/loader.service'; import { OrgUserService } from 'src/app/core/services/org-user.service'; +import { OnboardingStep } from './models/onboarding-step.enum'; +import { SpenderOnboardingService } from 'src/app/core/services/spender-onboarding.service'; +import { OrgSettingsService } from 'src/app/core/services/org-settings.service'; +import { Router } from '@angular/router'; @Component({ selector: 'app-spender-onboarding', @@ -14,7 +18,17 @@ export class SpenderOnboardingPage { userFullName: string; - constructor(private loaderService: LoaderService, private orgUserService: OrgUserService) {} + currentStep: OnboardingStep; + + onboardingStep: typeof OnboardingStep = OnboardingStep; + + constructor( + private loaderService: LoaderService, + private orgUserService: OrgUserService, + private spenderOnboardingService: SpenderOnboardingService, + private orgSettingsService: OrgSettingsService, + private router: Router + ) {} ionViewWillEnter(): void { this.isLoading = true; @@ -28,5 +42,26 @@ export class SpenderOnboardingPage { }) ) .subscribe(); + + const isRtfEnabled = orgSettings.visa_enrollment_settings.enabled && mastercard_enrollment_settings.enabled; + const isAmexFeedEnabled = orgSettings.amex_feed_enrollment_settings.enabled; + this.spenderOnboardingService.getOnboardingStatus().subscribe((status) => { + if (isAmexFeedEnabled && !isRtfEnabled) { + this.currentStep === OnboardingStep.OPT_IN; + } else if (isRtfEnabled) { + // TODO: Add checks for status in connect card and opt in component prs + } else { + this.router.navigate(['/', 'enterprise', 'my_dashboard']); + } + }); + } + + skipOnboardingStep(): void { + if (this.currentStep === OnboardingStep.CONNECT_CARD) { + this.spenderOnboardingService.skipConnectCardsStep(); + } + if (this.currentStep === OnboardingStep.OPT_IN) { + this.spenderOnboardingService.skipSmsOptInStep(); + } } } From 35c37cc73767f5d0502d0a87fe31c5817066cc8e Mon Sep 17 00:00:00 2001 From: aastha Date: Mon, 23 Dec 2024 09:31:39 +0530 Subject: [PATCH 03/12] feat: Add tests and component logic --- .../core/mock-data/onboarding-status.data.ts | 14 +++ .../spender-onboarding.service.spec.ts | 109 ++++++++++++------ .../services/spender-onboarding.service.ts | 11 +- .../spender-onboarding.module.ts | 16 ++- .../spender-onboarding.page.html | 6 + .../spender-onboarding.page.scss | 1 + .../spender-onboarding.page.spec.ts | 106 +++++++++++++++++ .../spender-onboarding.page.ts | 56 ++++++--- 8 files changed, 259 insertions(+), 60 deletions(-) create mode 100644 src/app/core/mock-data/onboarding-status.data.ts diff --git a/src/app/core/mock-data/onboarding-status.data.ts b/src/app/core/mock-data/onboarding-status.data.ts new file mode 100644 index 0000000000..1a2fa21048 --- /dev/null +++ b/src/app/core/mock-data/onboarding-status.data.ts @@ -0,0 +1,14 @@ +import deepFreeze from 'deep-freeze-strict'; +import { OnboardingStatus } from '../models/onboarding-status.model'; +import { OnboardingState } from '../models/onboarding-state.enum'; + +export const onboardingStatusData: OnboardingStatus = deepFreeze({ + user_id: 'us1ymEVgUKqb', + org_id: 'orOTDe765hQp', + step_connect_cards_is_configured: false, + step_connect_cards_is_skipped: false, + step_sms_opt_in_is_configured: false, + step_sms_opt_in_is_skipped: false, + step_show_welcome_modal_is_complete: false, + state: OnboardingState.YET_TO_START, +}); diff --git a/src/app/core/services/spender-onboarding.service.spec.ts b/src/app/core/services/spender-onboarding.service.spec.ts index d2cb0c6408..9d3d1fc071 100644 --- a/src/app/core/services/spender-onboarding.service.spec.ts +++ b/src/app/core/services/spender-onboarding.service.spec.ts @@ -3,8 +3,8 @@ import { SpenderOnboardingService } from './spender-onboarding.service'; import { SpenderPlatformV1ApiService } from './spender-platform-v1-api.service'; import { OnboardingStepStatus } from '../models/onboarding-step-status.model'; import { of } from 'rxjs'; - -// Add any other imports as necessary from the original TasksService spec +import { onboardingStatusData } from '../mock-data/onboarding-status.data'; +import { OnboardingWelcomeStepStatus } from '../models/onboarding-welcome-step-status.model'; describe('SpenderOnboardingService', () => { let spenderOnboardingService: SpenderOnboardingService; @@ -29,21 +29,18 @@ describe('SpenderOnboardingService', () => { ) as jasmine.SpyObj; }); - fit('getOnboardingStatus(): should get onboarding status', (done) => { - const onboardingResponse: OnboardingStepStatus = { - is_configured: true, - is_skipped: false, - }; + it('getOnboardingStatus(): should get onboarding status', (done) => { + const onboardingResponse = onboardingStatusData; spenderPlatformV1ApiService.get.and.returnValue(of({ data: onboardingResponse })); spenderOnboardingService.getOnboardingStatus().subscribe((res) => { expect(res).toEqual(onboardingResponse); - expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/spender/onboarding'); + expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/onboarding'); done(); }); }); - fit('processConnnectCardsStep(): should process connect card step', (done) => { + it('processConnnectCardsStep(): should process connect card step', (done) => { const onboardingRequestResponse: OnboardingStepStatus = { is_configured: true, is_skipped: false, @@ -52,55 +49,95 @@ describe('SpenderOnboardingService', () => { spenderOnboardingService.processConnectCardsStep(onboardingRequestResponse).subscribe((res) => { expect(res).toEqual(onboardingRequestResponse); - expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith( - '/spender/onboarding/process_step_connect_cards', - onboardingRequestResponse - ); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith('/onboarding/process_step_connect_cards', { + data: onboardingRequestResponse, + }); done(); }); }); - fit('processWelcomeModalStep(): should get category count', (done) => { + it('processSmsOptInStep(): should process opt in step', (done) => { const onboardingRequestResponse: OnboardingStepStatus = { is_configured: true, is_skipped: false, }; spenderPlatformV1ApiService.post.and.returnValue(of({ data: onboardingRequestResponse })); - spenderOnboardingService.processConnectCardsStep(onboardingRequestResponse).subscribe((res) => { + spenderOnboardingService.processSmsOptInStep(onboardingRequestResponse).subscribe((res) => { expect(res).toEqual(onboardingRequestResponse); - expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith( - '/spender/onboarding/process_step_connect_cards', - onboardingRequestResponse - ); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith('/onboarding/process_step_sms_opt_in', { + data: onboardingRequestResponse, + }); done(); }); }); - fit('getActiveCategoriesCount(): should get category count', (done) => { - const onboardingResponse: OnboardingStepStatus = { - is_configured: true, - is_skipped: false, + it('processWelcomeModalStep(): should get category count', (done) => { + const onboardingRequestResponse: OnboardingWelcomeStepStatus = { + is_complete: true, }; - spenderPlatformV1ApiService.get.and.returnValue(of({ data: onboardingResponse })); + spenderPlatformV1ApiService.post.and.returnValue(of({ data: onboardingRequestResponse })); - spenderOnboardingService.getOnboardingStatus().subscribe((res) => { - expect(res).toEqual(onboardingResponse); - expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/spender/onboarding'); + spenderOnboardingService.processWelcomeModalStep(onboardingRequestResponse).subscribe((res) => { + expect(res).toEqual(onboardingRequestResponse); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith('/onboarding/process_step_show_welcome_modal', { + data: onboardingRequestResponse, + }); done(); }); }); - fit('getActiveCategoriesCount(): should get category count', (done) => { - const onboardingResponse: OnboardingStepStatus = { - is_configured: true, - is_skipped: false, - }; - spenderPlatformV1ApiService.get.and.returnValue(of({ data: onboardingResponse })); + it('markWelcomeModalStepAsComplete(): should call processWelcomeModalStep with the correct data', (done) => { + const mockData: OnboardingWelcomeStepStatus = { is_complete: true }; + spyOn(spenderOnboardingService, 'processWelcomeModalStep').and.returnValue(of(mockData)); - spenderOnboardingService.getOnboardingStatus().subscribe((res) => { - expect(res).toEqual(onboardingResponse); - expect(spenderPlatformV1ApiService.get).toHaveBeenCalledOnceWith('/spender/onboarding'); + spenderOnboardingService.markWelcomeModalStepAsComplete().subscribe((result) => { + expect(spenderOnboardingService.processWelcomeModalStep).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + done(); + }); + }); + + it('markConnectCardsStepAsComplete(): should call processConnectCardsStep with the correct data', (done) => { + const mockData: OnboardingStepStatus = { is_configured: true, is_skipped: false }; + spyOn(spenderOnboardingService, 'processConnectCardsStep').and.returnValue(of(mockData)); + + spenderOnboardingService.markConnectCardsStepAsComplete().subscribe((result) => { + expect(spenderOnboardingService.processConnectCardsStep).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + done(); + }); + }); + + it('skipConnectCardsStep(): should call processConnectCardsStep with the correct data', (done) => { + const mockData: OnboardingStepStatus = { is_configured: false, is_skipped: true }; + spyOn(spenderOnboardingService, 'processConnectCardsStep').and.returnValue(of(mockData)); + + spenderOnboardingService.skipConnectCardsStep().subscribe((result) => { + expect(spenderOnboardingService.processConnectCardsStep).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + done(); + }); + }); + + it('markSmsOptInStepAsComplete(): should call processSmsOptInStep with the correct data', (done) => { + const mockData: OnboardingStepStatus = { is_configured: true, is_skipped: false }; + spyOn(spenderOnboardingService, 'processSmsOptInStep').and.returnValue(of(mockData)); + + spenderOnboardingService.markSmsOptInStepAsComplete().subscribe((result) => { + expect(spenderOnboardingService.processSmsOptInStep).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + done(); + }); + }); + + it('skipSmsOptInStep(): should call processSmsOptInStep with the correct data', (done) => { + const mockData: OnboardingStepStatus = { is_configured: false, is_skipped: true }; + spyOn(spenderOnboardingService, 'processSmsOptInStep').and.returnValue(of(mockData)); + + spenderOnboardingService.skipSmsOptInStep().subscribe((result) => { + expect(spenderOnboardingService.processSmsOptInStep).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); done(); }); }); diff --git a/src/app/core/services/spender-onboarding.service.ts b/src/app/core/services/spender-onboarding.service.ts index 323ca0e4bd..fa19eb93f3 100644 --- a/src/app/core/services/spender-onboarding.service.ts +++ b/src/app/core/services/spender-onboarding.service.ts @@ -4,6 +4,7 @@ import { SpenderPlatformV1ApiService } from './spender-platform-v1-api.service'; import { PlatformApiResponse } from '../models/platform/platform-api-response.model'; import { OnboardingWelcomeStepStatus } from '../models/onboarding-welcome-step-status.model'; import { OnboardingStepStatus } from '../models/onboarding-step-status.model'; +import { OnboardingStatus } from '../models/onboarding-status.model'; @Injectable({ providedIn: 'root', @@ -11,15 +12,15 @@ import { OnboardingStepStatus } from '../models/onboarding-step-status.model'; export class SpenderOnboardingService { constructor(private spenderPlatformV1ApiService: SpenderPlatformV1ApiService) {} - getOnboardingStatus(): Observable { + getOnboardingStatus(): Observable { return this.spenderPlatformV1ApiService - .get>('/spender/onboarding') + .get>('/onboarding') .pipe(map((res) => res.data)); } processConnectCardsStep(data: OnboardingStepStatus): Observable { return this.spenderPlatformV1ApiService - .post>('/spender/onboarding/process_step_connect_cards', { + .post>('/onboarding/process_step_connect_cards', { data, }) .pipe(map((res) => res.data)); @@ -27,13 +28,13 @@ export class SpenderOnboardingService { processSmsOptInStep(data: OnboardingStepStatus): Observable { return this.spenderPlatformV1ApiService - .post>('/spender/onboarding/process_step_sms_opt_in', { data }) + .post>('/onboarding/process_step_sms_opt_in', { data }) .pipe(map((res) => res.data)); } processWelcomeModalStep(data: OnboardingWelcomeStepStatus): Observable { return this.spenderPlatformV1ApiService - .post>('/spender/onboarding/process_step_show_welcome_modal', { + .post>('/onboarding/process_step_show_welcome_modal', { data, }) .pipe(map((res) => res.data)); diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts index 9f64b3b718..b1b3b09128 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.module.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.module.ts @@ -2,13 +2,23 @@ import { NgModule } from '@angular/core'; import { SharedModule } from 'src/app/shared/shared.module'; import { SpenderOnboardingPage } from './spender-onboarding.page'; import { IonicModule } from '@ionic/angular'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 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'; @NgModule({ - imports: [SharedModule, CommonModule, FormsModule, IonicModule, MatButtonModule, SpenderOnboardingRoutingModule], - declarations: [SpenderOnboardingPage], + imports: [ + SharedModule, + CommonModule, + FormsModule, + IonicModule, + MatButtonModule, + SpenderOnboardingRoutingModule, + FormsModule, + ReactiveFormsModule, + ], + declarations: [SpenderOnboardingPage, SpenderOnboardingConnectCardStepComponent], }) 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 6127034901..6a79858960 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.html +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.html @@ -25,5 +25,11 @@
Skip
+
+ +
diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.scss b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss index 5c241bf2f7..c9019abffd 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.scss +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss @@ -26,6 +26,7 @@ $toolbar-border: #ababab6b; border: 1px solid $border-info-lighter; background: $pure-white; box-shadow: -14px -6px 24px 0px #00000008; + height: 100%; } &__stepper-container { diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts index e69de29bb2..2f82c6e147 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.spec.ts @@ -0,0 +1,106 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { SpenderOnboardingPage } from './spender-onboarding.page'; +import { LoaderService } from 'src/app/core/services/loader.service'; +import { OrgUserService } from 'src/app/core/services/org-user.service'; +import { SpenderOnboardingService } from 'src/app/core/services/spender-onboarding.service'; +import { OrgSettingsService } from 'src/app/core/services/org-settings.service'; +import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.service'; +import { OnboardingStep } from './models/onboarding-step.enum'; +import { orgSettingsData } from 'src/app/core/test-data/accounts.service.spec.data'; +import { onboardingStatusData } from 'src/app/core/mock-data/onboarding-status.data'; +import { extendedOrgUserResponse } from 'src/app/core/test-data/tasks.service.spec.data'; + +describe('SpenderOnboardingPage', () => { + let component: SpenderOnboardingPage; + let fixture: ComponentFixture; + let loaderService: jasmine.SpyObj; + let orgUserService: jasmine.SpyObj; + let spenderOnboardingService: jasmine.SpyObj; + let orgSettingsService: jasmine.SpyObj; + let corporateCreditCardExpenseService: jasmine.SpyObj; + let router: jasmine.SpyObj; + + beforeEach(async () => { + const loaderServiceSpy = jasmine.createSpyObj('LoaderService', ['showLoader', 'hideLoader']); + const orgUserServiceSpy = jasmine.createSpyObj('OrgUserService', ['getCurrent']); + const spenderOnboardingServiceSpy = jasmine.createSpyObj('SpenderOnboardingService', [ + 'getOnboardingStatus', + 'skipConnectCardsStep', + 'markConnectCardsStepAsComplete', + 'skipSmsOptInStep', + 'markSmsOptInStepAsComplete', + ]); + const orgSettingsServiceSpy = jasmine.createSpyObj('OrgSettingsService', ['get']); + const corporateCreditCardExpenseServiceSpy = jasmine.createSpyObj('CorporateCreditCardExpenseService', [ + 'getCorporateCards', + ]); + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + await TestBed.configureTestingModule({ + declarations: [SpenderOnboardingPage], + providers: [ + { provide: LoaderService, useValue: loaderServiceSpy }, + { provide: OrgUserService, useValue: orgUserServiceSpy }, + { provide: SpenderOnboardingService, useValue: spenderOnboardingServiceSpy }, + { provide: OrgSettingsService, useValue: orgSettingsServiceSpy }, + { provide: CorporateCreditCardExpenseService, useValue: corporateCreditCardExpenseServiceSpy }, + { provide: Router, useValue: routerSpy }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SpenderOnboardingPage); + component = fixture.componentInstance; + + loaderService = TestBed.inject(LoaderService) as jasmine.SpyObj; + orgUserService = TestBed.inject(OrgUserService) as jasmine.SpyObj; + spenderOnboardingService = TestBed.inject(SpenderOnboardingService) as jasmine.SpyObj; + orgSettingsService = TestBed.inject(OrgSettingsService) as jasmine.SpyObj; + corporateCreditCardExpenseService = TestBed.inject( + CorporateCreditCardExpenseService + ) as jasmine.SpyObj; + router = TestBed.inject(Router) as jasmine.SpyObj; + }); + + it('ionViewWillEnter(): should show loader and fetch onboarding data on ionViewWillEnter', (done) => { + loaderService.showLoader.and.resolveTo(); + orgUserService.getCurrent.and.returnValue(of(extendedOrgUserResponse)); + orgSettingsService.get.and.returnValue(of(orgSettingsData)); + spenderOnboardingService.getOnboardingStatus.and.returnValue(of(onboardingStatusData)); + corporateCreditCardExpenseService.getCorporateCards.and.returnValue(of([])); + + component.ionViewWillEnter(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(loaderService.showLoader).toHaveBeenCalledTimes(1); + expect(component.userFullName).toBe('Aiyush'); + expect(component.currentStep).toBe(OnboardingStep.CONNECT_CARD); + expect(component.isLoading).toBeFalse(); + expect(loaderService.hideLoader).toHaveBeenCalled(); + done(); + }); + }); + + it('skipOnboardingStep(): should skip the current onboarding step', () => { + component.currentStep = OnboardingStep.CONNECT_CARD; + component.skipOnboardingStep(); + expect(spenderOnboardingService.skipConnectCardsStep).toHaveBeenCalled(); + + component.currentStep = OnboardingStep.OPT_IN; + component.skipOnboardingStep(); + expect(spenderOnboardingService.skipSmsOptInStep).toHaveBeenCalled(); + }); + + it('markStepAsComplete(): should mark the current step as complete', () => { + component.currentStep = OnboardingStep.CONNECT_CARD; + component.markStepAsComplete(); + expect(spenderOnboardingService.markConnectCardsStepAsComplete).toHaveBeenCalled(); + + component.currentStep = OnboardingStep.OPT_IN; + component.markStepAsComplete(); + expect(spenderOnboardingService.markSmsOptInStepAsComplete).toHaveBeenCalled(); + }); +}); diff --git a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts index 96aa26b24d..236cd20f06 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { from, map, switchMap } from 'rxjs'; +import { forkJoin, from, map, switchMap } from 'rxjs'; import { ExtendedOrgUser } from 'src/app/core/models/extended-org-user.model'; import { LoaderService } from 'src/app/core/services/loader.service'; import { OrgUserService } from 'src/app/core/services/org-user.service'; @@ -7,6 +7,7 @@ import { OnboardingStep } from './models/onboarding-step.enum'; import { SpenderOnboardingService } from 'src/app/core/services/spender-onboarding.service'; 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'; @Component({ selector: 'app-spender-onboarding', @@ -27,33 +28,47 @@ export class SpenderOnboardingPage { private orgUserService: OrgUserService, private spenderOnboardingService: SpenderOnboardingService, private orgSettingsService: OrgSettingsService, - private router: Router + private router: Router, + private corporateCreditCardExpenseService: CorporateCreditCardExpenseService ) {} ionViewWillEnter(): void { this.isLoading = true; from(this.loaderService.showLoader()) .pipe( - switchMap(() => this.orgUserService.getCurrent()), - map((eou: ExtendedOrgUser) => { + switchMap(() => + forkJoin([ + this.orgUserService.getCurrent(), + this.orgSettingsService.get(), + this.spenderOnboardingService.getOnboardingStatus(), + this.corporateCreditCardExpenseService.getCorporateCards(), + ]) + ), + map(([eou, orgSettings, onboardingStatus, corporateCards]) => { this.userFullName = eou.us.full_name; + 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; + } else if (isRtfEnabled) { + // If Connect Card was skipped earlier or Cards are already enrolled, then go to OPT_IN step + if ( + onboardingStatus.step_connect_cards_is_skipped || + onboardingStatus.step_connect_cards_is_configured || + rtfCards.length > 0 + ) { + this.currentStep = OnboardingStep.OPT_IN; + } else { + this.currentStep = OnboardingStep.CONNECT_CARD; + } + } this.isLoading = false; this.loaderService.hideLoader(); }) ) .subscribe(); - - const isRtfEnabled = orgSettings.visa_enrollment_settings.enabled && mastercard_enrollment_settings.enabled; - const isAmexFeedEnabled = orgSettings.amex_feed_enrollment_settings.enabled; - this.spenderOnboardingService.getOnboardingStatus().subscribe((status) => { - if (isAmexFeedEnabled && !isRtfEnabled) { - this.currentStep === OnboardingStep.OPT_IN; - } else if (isRtfEnabled) { - // TODO: Add checks for status in connect card and opt in component prs - } else { - this.router.navigate(['/', 'enterprise', 'my_dashboard']); - } - }); } skipOnboardingStep(): void { @@ -64,4 +79,13 @@ export class SpenderOnboardingPage { this.spenderOnboardingService.skipSmsOptInStep(); } } + + markStepAsComplete(): void { + if (this.currentStep === OnboardingStep.CONNECT_CARD) { + this.spenderOnboardingService.markConnectCardsStepAsComplete(); + } + if (this.currentStep === OnboardingStep.OPT_IN) { + this.spenderOnboardingService.markSmsOptInStepAsComplete(); + } + } } From 6d86ba2b4d9e8dccc886110972a4eb38248d998a Mon Sep 17 00:00:00 2001 From: aastha Date: Tue, 24 Dec 2024 11:09:57 +0530 Subject: [PATCH 04/12] Minor --- ...nboarding-connect-card-step.component.html | 90 +++++++++ ...nboarding-connect-card-step.component.scss | 180 ++++++++++++++++++ ...arding-connect-card-step.component.spec.ts | 0 ...-onboarding-connect-card-step.component.ts | 65 +++++++ 4 files changed, 335 insertions(+) create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.html create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.spec.ts create mode 100644 src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-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 new file mode 100644 index 0000000000..ca15e024f4 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.html @@ -0,0 +1,90 @@ +
+
+
Connect corporate card
+
+ This will help you bring your card transactions into Fyle as expenses instantly. +
+ +
+ 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. + + + + + {{ enrollmentFailureMessage }} + +
+
+
+ + Continue + +
+
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 new file mode 100644 index 0000000000..50e18fe6e3 --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss @@ -0,0 +1,180 @@ +@import '../../../../theme/colors.scss'; + +.connect-card { + position: relative; + height: 100%; + + &__body { + display: flex; + flex-direction: column; + justify-content: space-between; + } + &__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; + } +} diff --git a/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.spec.ts b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.spec.ts new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..b3eb0c429d --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AbstractControl, FormControl, ValidationErrors } from '@angular/forms'; +import { CardNetworkType } from 'src/app/core/enums/card-network-type'; +import { OrgSettings } from 'src/app/core/models/org-settings.model'; +import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.service'; +import { RealTimeFeedService } from 'src/app/core/services/real-time-feed.service'; + +@Component({ + selector: 'app-spender-onboarding-connect-card-step', + templateUrl: './spender-onboarding-connect-card-step.component.html', + styleUrls: ['./spender-onboarding-connect-card-step.component.scss'], +}) +export class SpenderOnboardingConnectCardStepComponent { + @Input() readOnly?: boolean = false; + + @Input() orgSettings: OrgSettings; + + @Output() isStepCompleted: EventEmitter = new EventEmitter(); + + cardForm: FormControl; + + isVisaRTFEnabled = false; + + isMastercardRTFEnabled = false; + + cardType = CardNetworkType; + + constructor( + private corporateCreditCardExpensesService: CorporateCreditCardExpenseService, + private realTimeFeedService: RealTimeFeedService + ) {} + + ionViewWillEnter(): void { + this.cardForm = new FormControl('', [this.cardNumberValidator.bind(this), this.cardNetworkValidator.bind(this)]); + } + + private cardNumberValidator(control: AbstractControl): ValidationErrors { + // Reactive forms are not strongly typed in Angular 13, so we need to cast the value to string + // 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 cardType = this.realTimeFeedService.getCardTypeFromNumber(cardNumber); + + if (cardType === CardNetworkType.VISA || cardType === CardNetworkType.MASTERCARD) { + return isValid && cardNumber.length === 16 ? null : { invalidCardNumber: true }; + } + + return isValid ? null : { invalidCardNumber: true }; + } + + private cardNetworkValidator(control: AbstractControl): ValidationErrors { + const cardNumber = control.value as string; + const cardType = this.realTimeFeedService.getCardTypeFromNumber(cardNumber); + + if ( + (!this.isVisaRTFEnabled && cardType === CardNetworkType.VISA) || + (!this.isMastercardRTFEnabled && cardType === CardNetworkType.MASTERCARD) + ) { + return { invalidCardNetwork: true }; + } + + return null; + } +} From 868f35fed8527ab305b686f18194a643b8b4239a Mon Sep 17 00:00:00 2001 From: aastha Date: Tue, 24 Dec 2024 12:03:43 +0530 Subject: [PATCH 05/12] Minor --- .../spender-onboarding-connect-card-step.component.html | 2 +- .../spender-onboarding-connect-card-step.component.scss | 8 ++++++++ .../fyle/spender-onboarding/spender-onboarding.page.scss | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) 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 ca15e024f4..229c2ff95a 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 @@ -77,7 +77,7 @@ -
+
Date: Thu, 26 Dec 2024 14:59:59 +0530 Subject: [PATCH 06/12] Minor --- ...nboarding-connect-card-step.component.html | 140 ++++++++------- ...nboarding-connect-card-step.component.scss | 8 + ...-onboarding-connect-card-step.component.ts | 167 +++++++++++++++++- 3 files changed, 248 insertions(+), 67 deletions(-) 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 229c2ff95a..8c31a5e04b 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 @@ -5,77 +5,94 @@ This will help you bring your card transactions into Fyle as expenses instantly.
-
- Corporate card -
- -
- +
+
+
+
+ Corporate card +
- +
+ - +
+ {{ card?.card_number || '' }} +
- -
+ -
+ -
- 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. - + Please enter a valid card number. - - - Enter a valid Mastercard number. If you have other cards, please contact your admin. - -
+ + Enter a valid Visa or Mastercard number. If you have other cards, please contact your admin. - - {{ enrollmentFailureMessage }} - -
+ + + 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. + + +
+
+
+
+
Continue 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 035f11119a..360329bd83 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 @@ -17,6 +17,14 @@ justify-content: flex-end; } + &__card-number-input { + width: fit-content !important; + margin-right: 24px; + &::placeholder { + word-spacing: 24px; + } + } + &__heading { color: $black; font-size: 20px; 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 b3eb0c429d..b38db0c174 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,16 +1,31 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { AbstractControl, FormControl, ValidationErrors } from '@angular/forms'; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormControl, + FormGroup, + ValidationErrors, + Validators, +} from '@angular/forms'; +import { PopoverController } from '@ionic/angular'; +import { catchError, concatMap, finalize, 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'; +import { PopoverCardsList } from 'src/app/core/models/popover-cards-list.model'; import { CorporateCreditCardExpenseService } from 'src/app/core/services/corporate-credit-card-expense.service'; import { RealTimeFeedService } from 'src/app/core/services/real-time-feed.service'; +import { PopupAlertComponent } from 'src/app/shared/components/popup-alert/popup-alert.component'; @Component({ selector: 'app-spender-onboarding-connect-card-step', templateUrl: './spender-onboarding-connect-card-step.component.html', styleUrls: ['./spender-onboarding-connect-card-step.component.scss'], }) -export class SpenderOnboardingConnectCardStepComponent { +export class SpenderOnboardingConnectCardStepComponent implements OnInit, OnChanges { @Input() readOnly?: boolean = false; @Input() orgSettings: OrgSettings; @@ -25,13 +40,153 @@ export class SpenderOnboardingConnectCardStepComponent { cardType = CardNetworkType; + enrollableCards: PlatformCorporateCard[]; + + cardValuesMap: Record = {}; + + rtfCardType: CardNetworkType; + + cardsList: PopoverCardsList = { + successfulCards: [], + failedCards: [], + }; + + fg: FormGroup; + constructor( private corporateCreditCardExpensesService: CorporateCreditCardExpenseService, - private realTimeFeedService: RealTimeFeedService + private realTimeFeedService: RealTimeFeedService, + private fb: FormBuilder, + private popoverController: PopoverController ) {} - ionViewWillEnter(): void { - this.cardForm = new FormControl('', [this.cardNumberValidator.bind(this), this.cardNetworkValidator.bind(this)]); + enrollCards(): void { + const cards = this.enrollableCards; + 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); + }) + ) + ) + ) + .subscribe(() => { + if (this.cardsList.failedCards.length > 0) { + this.showErrorPopover(); + } else { + this.isStepCompleted.emit(true); + } + }); + } + + generateMessage(): string { + if (this.cardsList.successfulCards.length > 0) { + return 'We ran into an issue while processing your request. You can cancel and retry connecting the failed card or proceed to the next step.'; + } else if (this.cardsList.successfulCards.length > 0) { + return ` + We ran into an issue while processing your request for the card ${this.cardsList.failedCards[0]}. + 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 + .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.`; + } + } + + showErrorPopover(): void { + const errorPopover = this.popoverController.create({ + componentProps: { + title: 'Status summary', + message: this.generateMessage(), + primaryCta: { + text: 'Proceed anyway', + action: 'close', + }, + secondaryCta: { + text: 'Cancel', + action: 'cancel', + }, + cardsList: this.cardsList.successfulCards.length > 0 ? this.cardsList : {}, + }, + component: PopupAlertComponent, + cssClass: 'pop-up-in-center', + }); + + from(errorPopover) + .pipe( + tap((errorPopover) => errorPopover.present()), + switchMap((errorPopover) => errorPopover.onWillDismiss()), + map((response: OverlayResponse<{ action?: string }>) => { + if (response?.data?.action === 'close') { + this.isStepCompleted.emit(true); + } + }) + ) + .subscribe(noop); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.orgSettings.currentValue) { + this.isVisaRTFEnabled = this.orgSettings.visa_enrollment_settings.enabled; + this.isMastercardRTFEnabled = this.orgSettings.mastercard_enrollment_settings.enabled; + } + } + + ngOnInit(): void { + this.fg = this.fb.group({}); + 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, + card_type: CardNetworkType.OTHERS, + }; + this.fg.addControl( + controlName, + this.fb.control('', [ + Validators.required, + Validators.maxLength(12), + this.cardNumberValidator.bind(this), + this.cardNetworkValidator.bind(this), + ]) + ); + }); + }) + ) + .subscribe(); + } + + onCardNumberUpdate(card: PlatformCorporateCard, inputControlName: string): void { + this.formatCardNumber(this.fg.controls[inputControlName]); + this.cardValuesMap[card.id].card_type = this.realTimeFeedService.getCardTypeFromNumber( + this.cardValuesMap[card.id].card_number + ); + } + + formatCardNumber(input: AbstractControl): void { + // Remove all non-numeric characters + let value = (input.value as string).replace(/\D/g, ''); + + // Format the value in groups of 4 + value = value.replace(/(\d{4})(?=\d)/g, '$1 '); + + // Set the formatted value back to the input + input.setValue(value); } private cardNumberValidator(control: AbstractControl): ValidationErrors { From 648f6c0e59f840dd5607827fddc93b8cd123b2fc Mon Sep 17 00:00:00 2001 From: aastha Date: Thu, 26 Dec 2024 15:00:49 +0530 Subject: [PATCH 07/12] Minor --- .../popup-alert/popup-alert.component.html | 24 +++++++ .../popup-alert/popup-alert.component.scss | 69 +++++++++++++++++++ .../popup-alert/popup-alert.component.ts | 6 +- src/assets/svg/check.svg | 2 +- 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/app/shared/components/popup-alert/popup-alert.component.html b/src/app/shared/components/popup-alert/popup-alert.component.html index 31223bf7bb..4dd9051ea9 100644 --- a/src/app/shared/components/popup-alert/popup-alert.component.html +++ b/src/app/shared/components/popup-alert/popup-alert.component.html @@ -5,6 +5,30 @@
+