diff --git a/src/app/core/models/enum/enum.model.ts b/src/app/core/models/enum/enum.model.ts index c4397d664..4e30d6ac5 100644 --- a/src/app/core/models/enum/enum.model.ts +++ b/src/app/core/models/enum/enum.model.ts @@ -42,6 +42,8 @@ export enum InAppIntegration { QBD_DIRECT = 'QuickBooks Connector' } +export type IntegrationAppKey = keyof typeof InAppIntegration; + export enum ToastSeverity { SUCCESS = 'success', ERROR = 'error', @@ -900,7 +902,8 @@ export enum SizeOption { export enum ThemeOption { BRAND = 'brand', LIGHT = 'light', - DARK = 'dark' + DARK = 'dark', + SUCCESS = 'success' } export enum QBDPreRequisiteState { diff --git a/src/app/core/models/integrations/integrations.model.ts b/src/app/core/models/integrations/integrations.model.ts index a59034ed9..c0e50d177 100644 --- a/src/app/core/models/integrations/integrations.model.ts +++ b/src/app/core/models/integrations/integrations.model.ts @@ -1,5 +1,16 @@ import { AccountingIntegrationApp, AppUrl, ClickEvent, InAppIntegration, IntegrationView } from "../enum/enum.model"; +export type Integration = { + id: number; + org_id: string; + org_name: string; + tpa_id: string; + tpa_name: string; + type: string; + is_active: boolean; + is_beta: boolean; +} + export type IntegrationsView = { [IntegrationView.ACCOUNTING]: boolean, [IntegrationView.ALL]: boolean, diff --git a/src/app/core/services/common/integrations.service.spec.ts b/src/app/core/services/common/integrations.service.spec.ts new file mode 100644 index 000000000..6ffdba687 --- /dev/null +++ b/src/app/core/services/common/integrations.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { IntegrationsService } from './integrations.service'; + +xdescribe('IntegrationsService', () => { + let service: IntegrationsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(IntegrationsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/common/integrations.service.ts b/src/app/core/services/common/integrations.service.ts new file mode 100644 index 000000000..568ca79c8 --- /dev/null +++ b/src/app/core/services/common/integrations.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { AppUrl } from '../../models/enum/enum.model'; +import { ApiService } from './api.service'; +import { HelperService } from './helper.service'; +import { Observable } from 'rxjs'; +import { Integration } from '../../models/integrations/integrations.model'; + +@Injectable({ + providedIn: 'root' +}) +export class IntegrationsService { + constructor( + private apiService: ApiService, + private helper: HelperService + ) { + } + + getIntegrations(): Observable { + this.helper.setBaseApiURL(AppUrl.INTEGRATION); + return this.apiService.get(`/integrations/`, {}); + } +} diff --git a/src/app/integrations/integrations-routing.module.ts b/src/app/integrations/integrations-routing.module.ts index 30f044313..5b7535dcb 100644 --- a/src/app/integrations/integrations-routing.module.ts +++ b/src/app/integrations/integrations-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { IntegrationsComponent } from './integrations.component'; import { LandingComponent } from './landing/landing.component'; +import { LandingV2Component } from './landing-v2/landing-v2.component'; const routes: Routes = [ { @@ -12,6 +13,10 @@ const routes: Routes = [ path: 'landing', component: LandingComponent }, + { + path: 'landing_v2', + component: LandingV2Component + }, { path: 'bamboo_hr', loadChildren: () => import('./bamboo-hr/bamboo-hr.module').then(m => m.BambooHrModule) diff --git a/src/app/integrations/integrations.module.ts b/src/app/integrations/integrations.module.ts index b33f8fd5f..4b96692ce 100644 --- a/src/app/integrations/integrations.module.ts +++ b/src/app/integrations/integrations.module.ts @@ -10,6 +10,7 @@ import { SharedModule } from '../shared/shared.module'; import { Sage300Component } from './sage300/sage300.component'; import { XeroComponent } from './xero/xero.component'; import { TravelperkComponent } from './travelperk/travelperk.component'; +import { LandingV2Component } from './landing-v2/landing-v2.component'; @NgModule({ declarations: [ @@ -18,7 +19,8 @@ import { TravelperkComponent } from './travelperk/travelperk.component'; QbdComponent, Sage300Component, XeroComponent, - TravelperkComponent + TravelperkComponent, + LandingV2Component ], imports: [ CommonModule, diff --git a/src/app/integrations/landing-v2/landing-v2.component.html b/src/app/integrations/landing-v2/landing-v2.component.html new file mode 100644 index 000000000..0e25d893c --- /dev/null +++ b/src/app/integrations/landing-v2/landing-v2.component.html @@ -0,0 +1,239 @@ +
+
+
+

+ List of integrations +

+ +

+ If your company uses any of the applications listed below, you can easily integrate them with Fyle. +
+ Need an integration we don't support yet? Let us know at support{{'@'}}fylehq.com +

+
+
+
+ All +

+
+
+ Accounting +

+
+
+ HRMS +

+
+
+ Travel +

+
+
+ +
+
+
+
+ + @if (isAppConnected('NETSUITE')) { + + } @else { + + } +
+
+ + NetSuite + + + Accounting + +
+
+
+
+
+ + @if (isAppConnected('INTACCT')) { + + } @else { + + } +
+
+ + Sage Intacct + + + Accounting + +
+
+
+
+ + @if (isAppConnected('QBO')) { + + } @else { + + } +
+
+ + QuickBooks Online + + + Accounting + +
+
+
+
+ + @if (isAppConnected('XERO')) { + + } @else { + + } +
+
+ + Xero + + + Accounting + +
+
+
+
+ + @if (isAppConnected('QBD')) { + + } @else { + + } +
+
+ + QuickBooks Desktop (IIF) + + + Accounting + +
+
+ +
+
+ + @if (isAppConnected('QBD_DIRECT')) { + + } @else { + + } +
+ +
+ QuickBooks Desktop (Web Connector) +
Accounting
+
+ +
+
+
+
+ + @if (isAppConnected('SAGE300')) { + + } @else { + + } +
+ +
+ Sage 300 CRE +
Accounting
+
+ +
+
+
+
+ + @if (isAppConnected('BUSINESS_CENTRAL')) { + + } @else { + + } +
+ +
+ Dynamics 365 Business Central +
Accounting
+
+ +
+
+ +
+
+
+ + @if (isAppConnected('BAMBOO_HR')) { + + } @else { + + } +
+
+ + BambooHR + + + HRMS + +
+
+
+ +
+
+
+ + @if (isAppConnected('TRAVELPERK')) { + + } @else { + + } +
+
+ + TravelPerk + + + Travel + +
+
+
+
+
+
\ No newline at end of file diff --git a/src/app/integrations/landing-v2/landing-v2.component.scss b/src/app/integrations/landing-v2/landing-v2.component.scss new file mode 100644 index 000000000..198461ffb --- /dev/null +++ b/src/app/integrations/landing-v2/landing-v2.component.scss @@ -0,0 +1,72 @@ + +.landing-v2 { + &--container { + @apply tw-p-6 tw-flex tw-flex-col tw-gap-6; + @apply tw-rounded-8-px tw-border tw-border-solid tw-border-separator tw-bg-white; + } + + &--active-tag { + @apply tw-bg-slightly-normal-text-color tw-h-2-px tw-w-[100%] tw-rounded-tr-4-px tw-rounded-tl-4-px tw-mt-4; + } + + &--tab { + @apply tw-flex tw-flex-col tw-justify-between tw-items-center tw-cursor-pointer tw-pt-4 tw-px-6; + } + + &--divider { + @apply tw-h-1-px tw-w-[100%] tw-bg-separator; + } + + &--section-divider { + @extend .landing-v2--divider; + @apply tw-my-40-px; + } + + &--accounting-app { + @apply tw-h-full tw-flex tw-flex-col tw-p-4 tw-gap-4 tw-justify-evenly tw-cursor-pointer tw-rounded-8-px tw-border tw-border-separator tw-bg-white; + @apply tw-transition-shadow; + + img { + @apply tw-h-[40px]; + } + + .btn-connect { + @apply tw-text-14-px tw-hidden; + } + } + + &--accounting-app-name { + @apply tw-text-14-px tw-leading-[20px] tw-flex tw-text-text-primary; + } + + &--accounting-app-type { + @apply tw-text-12-px tw-leading-[15px] tw-text-text-tertiary; + } + + &--accounting-app-open { + @apply tw-pl-6-px tw-pt-3-px tw-text-slightly-normal-text-color; + } + + &--section-heading { + @apply tw-text-18-px tw-pb-8-px tw-text-text-primary tw-font-500; + } + + &--section-caption { + @apply tw-text-text-tertiary tw-text-14-px; + } + + &--other-integrations { + @apply tw-grid tw-grid-cols-3 tw-mt-50-px; + } + + &--other-integration-app { + @apply tw-flex tw-items-center tw-justify-center; + } +} + +.landing-v2--accounting-app:hover { + box-shadow: 0px 4px 4px 0px rgba(44, 48, 78, 0.10); + .btn-connect { + @apply tw-block; + } +} \ No newline at end of file diff --git a/src/app/integrations/landing-v2/landing-v2.component.spec.ts b/src/app/integrations/landing-v2/landing-v2.component.spec.ts new file mode 100644 index 000000000..93c484157 --- /dev/null +++ b/src/app/integrations/landing-v2/landing-v2.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LandingV2Component } from './landing-v2.component'; + +xdescribe('LandingV2Component', () => { + let component: LandingV2Component; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LandingV2Component] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LandingV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/integrations/landing-v2/landing-v2.component.ts b/src/app/integrations/landing-v2/landing-v2.component.ts new file mode 100644 index 000000000..9a5062d93 --- /dev/null +++ b/src/app/integrations/landing-v2/landing-v2.component.ts @@ -0,0 +1,254 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { AccountingIntegrationApp, InAppIntegration, IntegrationAppKey, IntegrationView, ThemeOption } from 'src/app/core/models/enum/enum.model'; +import { InAppIntegrationUrlMap, IntegrationCallbackUrl, IntegrationsView } from 'src/app/core/models/integrations/integrations.model'; +import { EventsService } from 'src/app/core/services/common/events.service'; +import { OrgService } from 'src/app/core/services/org/org.service'; +import { environment } from 'src/environments/environment'; +import { Org } from 'src/app/core/models/org/org.model'; +import { SiAuthService } from 'src/app/core/services/si/si-core/si-auth.service'; +import { StorageService } from 'src/app/core/services/common/storage.service'; +import { Token } from 'src/app/core/models/misc/token.model'; +import { MinimalUser } from 'src/app/core/models/db/user.model'; +import { brandingConfig, brandingFeatureConfig } from 'src/app/branding/branding-config'; +import { QboAuthService } from 'src/app/core/services/qbo/qbo-core/qbo-auth.service'; +import { XeroAuthService } from 'src/app/core/services/xero/xero-core/xero-auth.service'; +import { exposeAppConfig } from 'src/app/branding/expose-app-config'; +import { NetsuiteAuthService } from 'src/app/core/services/netsuite/netsuite-core/netsuite-auth.service'; +import { IntegrationsService } from 'src/app/core/services/common/integrations.service'; + +@Component({ + selector: 'app-landing-v2', + templateUrl: './landing-v2.component.html', + styleUrl: './landing-v2.component.scss' +}) +export class LandingV2Component implements OnInit { + + IntegrationsView = IntegrationView; + + AccountingIntegrationApp = AccountingIntegrationApp; + + InAppIntegration = InAppIntegration; + + org: Org = this.orgService.getCachedOrg(); + + private connectedApps: IntegrationAppKey[]; + + readonly exposeC1Apps = brandingFeatureConfig.exposeC1Apps; + + private readonly integrationTabsInitialState: IntegrationsView = { + [IntegrationView.ACCOUNTING]: false, + [IntegrationView.HRMS]: false, + [IntegrationView.ALL]: false, + [IntegrationView.TRAVEL]: false + }; + + integrationTabs: IntegrationsView = { + [IntegrationView.ACCOUNTING]: false, + [IntegrationView.HRMS]: false, + [IntegrationView.ALL]: true, + [IntegrationView.TRAVEL]: false + }; + + private readonly integrationCallbackUrlMap: IntegrationCallbackUrl = { + [AccountingIntegrationApp.NETSUITE]: [`${environment.fyle_app_url}/netsuite`, environment.ns_client_id], + [AccountingIntegrationApp.QBO]: [`${environment.fyle_app_url}/quickbooks`, environment.qbo_client_id], + [AccountingIntegrationApp.SAGE_INTACCT]: [`${environment.fyle_app_url}/sage-intacct`, environment.si_client_id], + [AccountingIntegrationApp.XERO]: [`${environment.fyle_app_url}/xero`, environment.xero_client_id] + }; + + private readonly inAppIntegrationUrlMap: InAppIntegrationUrlMap = { + [InAppIntegration.BAMBOO_HR]: '/integrations/bamboo_hr/', + [InAppIntegration.QBD]: '/integrations/qbd/', + [InAppIntegration.TRAVELPERK]: '/integrations/travelperk/', + [InAppIntegration.INTACCT]: '/integrations/intacct', + [InAppIntegration.QBO]: '/integrations/qbo', + [InAppIntegration.SAGE300]: '/integrations/sage300', + [InAppIntegration.BUSINESS_CENTRAL]: '/integrations/business_central', + [InAppIntegration.NETSUITE]: '/integrations/netsuite', + [InAppIntegration.XERO]: '/integrations/xero', + [InAppIntegration.QBD_DIRECT]: '/integrations/qbd_direct' + }; + + private readonly accountingIntegrationUrlMap = { + [AccountingIntegrationApp.NETSUITE]: '/integrations/netsuite', + [AccountingIntegrationApp.SAGE_INTACCT]: '/integrations/intacct', + [AccountingIntegrationApp.QBO]: '/integrations/qbo', + [AccountingIntegrationApp.XERO]: '/integrations/xero' + }; + + private readonly tpaNameToIntegrationKeyMap: Record = { + 'Fyle Netsuite Integration': 'NETSUITE', + 'Fyle Sage Intacct Integration': 'INTACCT', + 'Fyle Quickbooks Integration': 'QBO', + 'Fyle Xero Integration': 'XERO', + 'Fyle Quickbooks Desktop (IIF) Integration': 'QBD', + 'Fyle Quickbooks Desktop Integration': 'QBD_DIRECT', + 'Fyle Sage 300 Integration': 'SAGE300', + 'Fyle Business Central Integration': 'BUSINESS_CENTRAL', + 'Fyle TravelPerk Integration': 'TRAVELPERK', + 'Fyle BambooHR Integration': 'BAMBOO_HR' + }; + + readonly brandingConfig = brandingConfig; + + readonly isINCluster = this.storageService.get('cluster-domain')?.includes('in1'); + + readonly exposeApps = !this.isINCluster ? exposeAppConfig[brandingConfig.brandId][brandingConfig.envId] : exposeAppConfig[brandingConfig.brandId]['production-1-in']; + + readonly orgsToHideSage300BetaBadge = [ + 'or4xcag0tfuk', + 'orC3X89Ku6wE', + 'orUpM1wmNBJX', + 'orOiAVGiOnrh' + ]; + + readonly orgsToHideBusinessCentralBetaBadge = [ + 'orvysp2iDQKH', + 'orRuH2BEKRnW' + ]; + + readonly ThemeOption = ThemeOption; + + constructor( + private eventsService: EventsService, + private qboAuthService: QboAuthService, + private xeroAuthService: XeroAuthService, + private nsAuthService: NetsuiteAuthService, + private router: Router, + private siAuthService: SiAuthService, + private storageService: StorageService, + private orgService: OrgService, + private integrationService: IntegrationsService + ) { } + + + switchView(clickedView: IntegrationView): void { + const initialState = Object.create(this.integrationTabsInitialState); + + // Resetting to initial state and setting clicked view to true + this.integrationTabs = initialState; + this.integrationTabs[clickedView] = true; + } + + isAppShown(appKey: IntegrationAppKey) { + // If this app disabled for this org + if ( + (appKey === 'BUSINESS_CENTRAL' && !this.org.allow_dynamics) || + (appKey === 'QBD_DIRECT' && !this.org.allow_qbd_direct_integration) || + (appKey === 'TRAVELPERK' && !this.org.allow_travelperk) + ) { + return false; + } + + // If this app allowed and all apps are shown + if (this.integrationTabs.ALL) { + return true; + } + + const allAppKeys = Object.keys(InAppIntegration) as IntegrationAppKey[]; + + if (appKey === 'BAMBOO_HR') { + return this.exposeApps.BAMBOO && this.integrationTabs.HRMS; + } + + if (appKey === 'TRAVELPERK') { + return this.exposeApps.TRAVELPERK && this.integrationTabs.TRAVEL; + } + + // If the app was not BAMBOO_HR or TRAVELPERK, it must be an accounting app + if (allAppKeys.includes(appKey)) { + return this.exposeApps[appKey] && this.integrationTabs.ACCOUNTING; + } + + // TS catch-all (shouln't reach here) + return false; + } + + isAppConnected(appKey: IntegrationAppKey) { + return this.connectedApps?.includes(appKey); + } + + openAccountingIntegrationApp(accountingIntegrationApp: AccountingIntegrationApp): void { + + // For local dev, we perform auth via loginWithRefreshToken on Fyle login redirect (/auth/redirect) + // So we can simply redirect to the integration page. + if (!environment.production) { + this.router.navigate([this.accountingIntegrationUrlMap[accountingIntegrationApp]]); + return; + } + + const payload = { + callbackUrl: this.integrationCallbackUrlMap[accountingIntegrationApp][0], + clientId: this.integrationCallbackUrlMap[accountingIntegrationApp][1] + }; + + this.eventsService.postEvent(payload); + } + + openInAppIntegration(inAppIntegration: InAppIntegration): void { + this.router.navigate([this.inAppIntegrationUrlMap[inAppIntegration]]); + } + + private loginAndRedirectToInAppIntegration(redirectUri: string, inAppIntegration: InAppIntegration): void { + const authCode = redirectUri.split('code=')[1].split('&')[0]; + let login$; + if (inAppIntegration === InAppIntegration.INTACCT) { + login$ = this.siAuthService.loginWithAuthCode(authCode); + } else if (inAppIntegration === InAppIntegration.QBO) { + login$ = this.qboAuthService.loginWithAuthCode(authCode); + } else if (inAppIntegration === InAppIntegration.XERO) { + login$ = this.xeroAuthService.login(authCode); + } else if (inAppIntegration === InAppIntegration.NETSUITE) { + login$ = this.nsAuthService.loginWithAuthCode(authCode); + } else { + return; + } + + login$.subscribe((token: Token) => { + const user: MinimalUser = { + 'email': token.user.email, + 'access_token': token.access_token, + 'refresh_token': token.refresh_token, + 'full_name': token.user.full_name, + 'user_id': token.user.user_id, + 'org_id': token.user.org_id, + 'org_name': token.user.org_name + }; + this.storageService.set('user', user); + this.openInAppIntegration(inAppIntegration); + }); + } + + private setupLoginWatcher(): void { + this.eventsService.sageIntacctLogin.subscribe((redirectUri: string) => { + this.loginAndRedirectToInAppIntegration(redirectUri, InAppIntegration.INTACCT); + }); + + this.eventsService.qboLogin.subscribe((redirectUri: string) => { + this.loginAndRedirectToInAppIntegration(redirectUri, InAppIntegration.QBO); + }); + + this.eventsService.xeroLogin.subscribe((redirectUri: string) => { + this.loginAndRedirectToInAppIntegration(redirectUri, InAppIntegration.XERO); + }); + + this.eventsService.netsuiteLogin.subscribe((redirectUri: string) => { + this.loginAndRedirectToInAppIntegration(redirectUri, InAppIntegration.NETSUITE); + }); + } + + private storeConnectedApps() { + this.integrationService.getIntegrations().subscribe(integrations => { + const tpaNames = integrations.map(integration => integration.tpa_name); + const connectedApps = tpaNames.map(tpaName => this.tpaNameToIntegrationKeyMap[tpaName]); + + this.connectedApps = connectedApps; + }); + } + + ngOnInit(): void { + this.setupLoginWatcher(); + this.storeConnectedApps(); + } +} diff --git a/src/app/shared/components/core/badge/badge.component.scss b/src/app/shared/components/core/badge/badge.component.scss index e6de37515..b9f32e6cd 100644 --- a/src/app/shared/components/core/badge/badge.component.scss +++ b/src/app/shared/components/core/badge/badge.component.scss @@ -22,6 +22,11 @@ @apply tw-text-badge-dark-text-color; } +.theme-success { + @apply tw-bg-bg-success-light tw-border tw-border-solid tw-border-border-success-light; + @apply tw-text-text-success; +} + .size-large { @apply tw-min-w-24-px; @apply tw-min-h-24-px; diff --git a/src/assets/logos/bamboo-hr-logo.png b/src/assets/logos/bamboo-hr-logo.png new file mode 100644 index 000000000..c9a2b05be Binary files /dev/null and b/src/assets/logos/bamboo-hr-logo.png differ diff --git a/src/assets/logos/intacct-logo-new.png b/src/assets/logos/intacct-logo-new.png new file mode 100644 index 000000000..92348df65 Binary files /dev/null and b/src/assets/logos/intacct-logo-new.png differ diff --git a/src/assets/logos/netsuite-logo-new.png b/src/assets/logos/netsuite-logo-new.png new file mode 100644 index 000000000..9ba043ed0 Binary files /dev/null and b/src/assets/logos/netsuite-logo-new.png differ diff --git a/src/assets/logos/sage300-logo.png b/src/assets/logos/sage300-logo.png new file mode 100644 index 000000000..6c7bf2811 Binary files /dev/null and b/src/assets/logos/sage300-logo.png differ diff --git a/src/assets/logos/travelperk-logo.png b/src/assets/logos/travelperk-logo.png new file mode 100644 index 000000000..c49a706be Binary files /dev/null and b/src/assets/logos/travelperk-logo.png differ diff --git a/src/assets/logos/xero-logo-new.png b/src/assets/logos/xero-logo-new.png new file mode 100644 index 000000000..25fddfa47 Binary files /dev/null and b/src/assets/logos/xero-logo-new.png differ