From b003ae1f6330a94356419cd22c5d21ec71b0c7b8 Mon Sep 17 00:00:00 2001 From: Aastha Bist Date: Sun, 22 Dec 2024 21:02:35 +0530 Subject: [PATCH 1/2] feat: initial template for onboarding home page (#3383) --- 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 0c539495032488d521d9cfcda05d20667c8079a0 Mon Sep 17 00:00:00 2001 From: Aastha Bist Date: Thu, 26 Dec 2024 10:19:01 +0530 Subject: [PATCH 2/2] feat: controller logic and tests for onboarding home page (#3387) --- .../core/mock-data/onboarding-status.data.ts | 14 ++ .../spender-onboarding.service.spec.ts | 144 ++++++++++++++ .../services/spender-onboarding.service.ts | 13 +- .../models/onboarding-step.enum.ts | 4 + ...nboarding-connect-card-step.component.html | 90 +++++++++ ...nboarding-connect-card-step.component.scss | 188 ++++++++++++++++++ ...arding-connect-card-step.component.spec.ts | 0 ...-onboarding-connect-card-step.component.ts | 65 ++++++ .../spender-onboarding.module.ts | 16 +- .../spender-onboarding.page.scss | 5 + .../spender-onboarding.page.spec.ts | 106 ++++++++++ .../spender-onboarding.page.ts | 68 ++++++- 12 files changed, 700 insertions(+), 13 deletions(-) create mode 100644 src/app/core/mock-data/onboarding-status.data.ts create mode 100644 src/app/fyle/spender-onboarding/models/onboarding-step.enum.ts 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/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 e69de29bb2..9d3d1fc071 100644 --- a/src/app/core/services/spender-onboarding.service.spec.ts +++ b/src/app/core/services/spender-onboarding.service.spec.ts @@ -0,0 +1,144 @@ +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'; +import { onboardingStatusData } from '../mock-data/onboarding-status.data'; +import { OnboardingWelcomeStepStatus } from '../models/onboarding-welcome-step-status.model'; + +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; + }); + + 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('/onboarding'); + done(); + }); + }); + + it('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('/onboarding/process_step_connect_cards', { + data: onboardingRequestResponse, + }); + 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.processSmsOptInStep(onboardingRequestResponse).subscribe((res) => { + expect(res).toEqual(onboardingRequestResponse); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith('/onboarding/process_step_sms_opt_in', { + data: onboardingRequestResponse, + }); + done(); + }); + }); + + it('processWelcomeModalStep(): should get category count', (done) => { + const onboardingRequestResponse: OnboardingWelcomeStepStatus = { + is_complete: true, + }; + spenderPlatformV1ApiService.post.and.returnValue(of({ data: onboardingRequestResponse })); + + spenderOnboardingService.processWelcomeModalStep(onboardingRequestResponse).subscribe((res) => { + expect(res).toEqual(onboardingRequestResponse); + expect(spenderPlatformV1ApiService.post).toHaveBeenCalledOnceWith('/onboarding/process_step_show_welcome_modal', { + data: onboardingRequestResponse, + }); + done(); + }); + }); + + it('markWelcomeModalStepAsComplete(): should call processWelcomeModalStep with the correct data', (done) => { + const mockData: OnboardingWelcomeStepStatus = { is_complete: true }; + spyOn(spenderOnboardingService, 'processWelcomeModalStep').and.returnValue(of(mockData)); + + 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 60a83ce27e..fa19eb93f3 100644 --- a/src/app/core/services/spender-onboarding.service.ts +++ b/src/app/core/services/spender-onboarding.service.ts @@ -4,22 +4,23 @@ 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', }) -export class OnboardingService { +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 OnboardingService { 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/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-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..229c2ff95a --- /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..035f11119a --- /dev/null +++ b/src/app/fyle/spender-onboarding/spender-onboarding-connect-card-step/spender-onboarding-connect-card-step.component.scss @@ -0,0 +1,188 @@ +@import '../../../../theme/colors.scss'; + +.connect-card { + 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; + } + + &__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; + } +} 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.scss b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss index 5c241bf2f7..c79f3c07dc 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.scss +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.scss @@ -20,12 +20,17 @@ $toolbar-border: #ababab6b; padding: 20px 16px; } + &__component-container { + height: 75%; + } + &__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; + 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 4b5e848f05..325afaab32 100644 --- a/src/app/fyle/spender-onboarding/spender-onboarding.page.ts +++ b/src/app/fyle/spender-onboarding/spender-onboarding.page.ts @@ -1,8 +1,13 @@ 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'; +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', @@ -14,19 +19,74 @@ 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, + 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(); } + + skipOnboardingStep(): void { + if (this.currentStep === OnboardingStep.CONNECT_CARD) { + this.spenderOnboardingService.skipConnectCardsStep(); + } + if (this.currentStep === OnboardingStep.OPT_IN) { + this.spenderOnboardingService.skipSmsOptInStep(); + } + } + + markStepAsComplete(): void { + if (this.currentStep === OnboardingStep.CONNECT_CARD) { + this.spenderOnboardingService.markConnectCardsStepAsComplete(); + } + if (this.currentStep === OnboardingStep.OPT_IN) { + this.spenderOnboardingService.markSmsOptInStepAsComplete(); + } + } + }