diff --git a/.prettierrc b/.prettierrc index 6718863f9..e0a33c788 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,5 +7,5 @@ "quoteProps": "as-needed", "jsxSingleQuote": false, "arrowParens": "always", - "endOfLine": "lf" + "endOfLine": "auto" } diff --git a/apps/api/package.json b/apps/api/package.json index 269bc3bfa..1c49813a5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@impler/api", - "version": "0.23.1", + "version": "0.24.0", "author": "implerhq", "license": "MIT", "private": true, @@ -19,9 +19,9 @@ "test": "cross-env TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --timeout 10000 --require ts-node/register --exit src/**/**/*.spec.ts" }, "dependencies": { - "@impler/dal": "^0.23.1", - "@impler/services": "^0.23.1", - "@impler/shared": "^0.23.1", + "@impler/dal": "^0.24.0", + "@impler/services": "^0.24.0", + "@impler/shared": "^0.24.0", "@nestjs/common": "^9.1.2", "@nestjs/core": "^9.1.2", "@nestjs/jwt": "^10.0.1", diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index db77ed587..a12ae6388 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -7,24 +7,38 @@ import { Controller, Get, Post, + Put, Res, UseGuards, UseInterceptors, } from '@nestjs/common'; import { IJwtPayload } from '@impler/shared'; +import { AuthService } from './services/auth.service'; import { IStrategyResponse } from '@shared/types/auth.types'; import { CONSTANTS, COOKIE_CONFIG } from '@shared/constants'; import { UserSession } from '@shared/framework/user.decorator'; import { ApiException } from '@shared/exceptions/api.exception'; import { StrategyUser } from './decorators/strategy-user.decorator'; -import { RegisterUserDto, LoginUserDto, RequestForgotPasswordDto, ResetPasswordDto } from './dtos'; import { - RegisterUser, - RegisterUserCommand, + RegisterUserDto, + LoginUserDto, + RequestForgotPasswordDto, + ResetPasswordDto, + OnboardUserDto, + VerifyDto, + UpdateUserDto, +} from './dtos'; +import { + Verify, LoginUser, + ResendOTP, + UpdateUser, + OnboardUser, + RegisterUser, ResetPassword, LoginUserCommand, + RegisterUserCommand, ResetPasswordCommand, RequestForgotPassword, RequestForgotPasswordCommand, @@ -36,8 +50,13 @@ import { @UseInterceptors(ClassSerializerInterceptor) export class AuthController { constructor( - private registerUser: RegisterUser, + private verify: Verify, + private resendOTP: ResendOTP, private loginUser: LoginUser, + private updateUser: UpdateUser, + private onboardUser: OnboardUser, + private authService: AuthService, + private registerUser: RegisterUser, private resetPassword: ResetPassword, private requestForgotPassword: RequestForgotPassword ) {} @@ -88,6 +107,18 @@ export class AuthController { return user; } + @Put('/me') + async updateUserRoute(@UserSession() user: IJwtPayload, @Body() body: UpdateUserDto, @Res() response: Response) { + const { success, token } = await this.updateUser.execute(user._id, body); + if (token) + response.cookie(CONSTANTS.AUTH_COOKIE_NAME, token, { + ...COOKIE_CONFIG, + domain: process.env.COOKIE_DOMAIN, + }); + + response.send({ success }); + } + @Get('/logout') logout(@Res() response: Response) { response.clearCookie(CONSTANTS.AUTH_COOKIE_NAME, { @@ -112,6 +143,50 @@ export class AuthController { response.send(registeredUser); } + @Post('/verify') + async verifyRoute(@Body() body: VerifyDto, @UserSession() user: IJwtPayload, @Res() response: Response) { + const { token, screen } = await this.verify.execute(user._id, { code: body.otp }); + response.cookie(CONSTANTS.AUTH_COOKIE_NAME, token, { + ...COOKIE_CONFIG, + domain: process.env.COOKIE_DOMAIN, + }); + + response.send({ screen }); + } + + @Post('/onboard') + async onboardUserRoute( + @Body() body: OnboardUserDto, + @UserSession() user: IJwtPayload, + @Res({ passthrough: true }) res: Response + ) { + const projectWithEnvironment = await this.onboardUser.execute({ + _userId: user._id, + projectName: body.projectName, + role: body.role, + companySize: body.companySize, + source: body.source, + }); + const token = this.authService.getSignedToken( + { + _id: user._id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + profilePicture: user.profilePicture, + isEmailVerified: user.isEmailVerified, + accessToken: projectWithEnvironment.environment.apiKeys[0].key, + }, + projectWithEnvironment.project._id + ); + res.cookie(CONSTANTS.AUTH_COOKIE_NAME, token, { + ...COOKIE_CONFIG, + domain: process.env.COOKIE_DOMAIN, + }); + + return projectWithEnvironment; + } + @Post('/login') async login(@Body() body: LoginUserDto, @Res() response: Response) { const loginUser = await this.loginUser.execute( @@ -145,4 +220,9 @@ export class AuthController { response.send(resetPassword); } + + @Post('verify/resend') + async resendOTPRoute(@UserSession() user: IJwtPayload) { + return await this.resendOTP.execute(user._id); + } } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 04fe5c754..feb9d92d2 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,16 +1,17 @@ -import { Global, MiddlewareConsumer, Module, NestModule, Provider, RequestMethod } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; import * as passport from 'passport'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { Global, MiddlewareConsumer, Module, NestModule, Provider, RequestMethod } from '@nestjs/common'; + import { USE_CASES } from './usecases'; import { CONSTANTS } from '@shared/constants'; -import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; +import { PaymentAPIService } from '@impler/services'; import { AuthService } from './services/auth.service'; import { SharedModule } from '../shared/shared.module'; -import { GitHubStrategy } from './services/passport/github.strategy'; -import { JwtStrategy } from './services/passport/jwt.strategy'; import { LeadService } from '@shared/services/lead.service'; -import { PaymentAPIService } from '@impler/services'; +import { JwtStrategy } from './services/passport/jwt.strategy'; +import { GitHubStrategy } from './services/passport/github.strategy'; const AUTH_STRATEGIES: Provider[] = [JwtStrategy]; @@ -33,7 +34,7 @@ if (process.env.GITHUB_OAUTH_CLIENT_ID) { }), ], controllers: [AuthController], - providers: [AuthService, ...AUTH_STRATEGIES, ...USE_CASES, LeadService, PaymentAPIService], + providers: [AuthService, LeadService, ...AUTH_STRATEGIES, ...USE_CASES, PaymentAPIService], exports: [AuthService], }) export class AuthModule implements NestModule { diff --git a/apps/api/src/app/auth/dtos/index.ts b/apps/api/src/app/auth/dtos/index.ts index a2ac8c5e5..acb869f52 100644 --- a/apps/api/src/app/auth/dtos/index.ts +++ b/apps/api/src/app/auth/dtos/index.ts @@ -2,3 +2,6 @@ export * from './register-user.dto'; export * from './login-user.dto'; export * from './request-forgot-password.dto'; export * from './reset-password.dto'; +export * from './onboard-user.dto'; +export * from './verify.dto'; +export * from './update-user.dto'; diff --git a/apps/api/src/app/auth/dtos/onboard-user.dto.ts b/apps/api/src/app/auth/dtos/onboard-user.dto.ts new file mode 100644 index 000000000..1847066d2 --- /dev/null +++ b/apps/api/src/app/auth/dtos/onboard-user.dto.ts @@ -0,0 +1,32 @@ +import { IsDefined, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class OnboardUserDto { + @ApiProperty({ + description: 'Size of the company', + }) + @IsString() + @IsDefined() + companySize: string; + + @ApiProperty({ + description: 'Role of the user', + }) + @IsString() + @IsDefined() + role: string; + + @ApiProperty({ + description: 'Source from where the user heard about us', + }) + @IsString() + @IsDefined() + source: string; + + @ApiProperty({ + description: 'Name of the Project', + }) + @IsString() + @IsDefined() + projectName: string; +} diff --git a/apps/api/src/app/auth/dtos/update-user.dto.ts b/apps/api/src/app/auth/dtos/update-user.dto.ts new file mode 100644 index 000000000..e3ada856f --- /dev/null +++ b/apps/api/src/app/auth/dtos/update-user.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsOptional } from 'class-validator'; + +export class UpdateUserDto { + @ApiProperty({ + description: 'Email of the user', + }) + @IsEmail() + @IsOptional() + email?: string; +} diff --git a/apps/api/src/app/auth/dtos/verify.dto.ts b/apps/api/src/app/auth/dtos/verify.dto.ts new file mode 100644 index 000000000..cf8c44b12 --- /dev/null +++ b/apps/api/src/app/auth/dtos/verify.dto.ts @@ -0,0 +1,7 @@ +import { IsDefined, IsString } from 'class-validator'; + +export class VerifyDto { + @IsString() + @IsDefined() + otp: string; +} diff --git a/apps/api/src/app/auth/services/auth.service.ts b/apps/api/src/app/auth/services/auth.service.ts index 66c02d7e7..90a69289f 100644 --- a/apps/api/src/app/auth/services/auth.service.ts +++ b/apps/api/src/app/auth/services/auth.service.ts @@ -3,9 +3,8 @@ import { JwtService } from '@nestjs/jwt'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { IJwtPayload } from '@impler/shared'; -import { CONSTANTS } from '@shared/constants'; +import { CONSTANTS, LEAD_SIGNUP_USING } from '@shared/constants'; import { PaymentAPIService } from '@impler/services'; -import { LeadService } from '@shared/services/lead.service'; import { UserEntity, UserRepository, EnvironmentRepository } from '@impler/dal'; import { UserNotFoundException } from '@shared/exceptions/user-not-found.exception'; import { IAuthenticationData, IStrategyResponse } from '@shared/types/auth.types'; @@ -15,7 +14,6 @@ import { IncorrectLoginCredentials } from '@shared/exceptions/incorrect-login-cr export class AuthService { constructor( private jwtService: JwtService, - private leadService: LeadService, private userRepository: UserRepository, private environmentRepository: EnvironmentRepository, private paymentAPIService: PaymentAPIService @@ -30,18 +28,14 @@ export class AuthService { if (!user) { const userObj: Partial = { email: profile.email, + isEmailVerified: true, firstName: profile.firstName, lastName: profile.lastName, + signupMethod: LEAD_SIGNUP_USING.GITHUB, profilePicture: profile.avatar_url, ...(provider ? { tokens: [provider] } : {}), }; user = await this.userRepository.create(userObj); - await this.leadService.createLead({ - 'First Name': user.firstName, - 'Last Name': user.lastName, - 'Lead Email': user.email, - 'Lead Source': 'Github Signup', - }); userCreated = true; const userData = { @@ -71,6 +65,7 @@ export class AuthService { lastName: user.lastName, profilePicture: user.profilePicture, accessToken: apiKey?.apiKey, + isEmailVerified: user.isEmailVerified, }, apiKey?.projectId ), @@ -104,6 +99,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, accessToken: apiKey?.apiKey, + isEmailVerified: user.isEmailVerified, }, apiKey?.projectId ), @@ -123,6 +119,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, accessToken: apiKey?.apiKey, + isEmailVerified: user.isEmailVerified, }, apiKey?.projectId ); @@ -134,6 +131,7 @@ export class AuthService { firstName: string; lastName: string; email: string; + isEmailVerified: boolean; profilePicture?: string; accessToken?: string; }, @@ -148,6 +146,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, email: user.email, + isEmailVerified: user.isEmailVerified, profilePicture: user.profilePicture, accessToken: user.accessToken, }, @@ -196,6 +195,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, accessToken: apiKey, + isEmailVerified: user.isEmailVerified, }, environment._projectId ); @@ -211,6 +211,7 @@ export class AuthService { firstName: user.firstName, lastName: user.lastName, accessToken: apiKey?.apiKey, + isEmailVerified: user.isEmailVerified, }, apiKey?.projectId ); diff --git a/apps/api/src/app/auth/usecases/index.ts b/apps/api/src/app/auth/usecases/index.ts index 72c9ba84f..e1c0d2d0e 100644 --- a/apps/api/src/app/auth/usecases/index.ts +++ b/apps/api/src/app/auth/usecases/index.ts @@ -1,20 +1,48 @@ import { LoginUser } from './login-user/login-user.usecase'; +import { UpdateUser } from './update-user/update-user.usecase'; +import { OnboardUser } from './onboard-user/onboard-user.usecase'; import { RegisterUser } from './register-user/register-user.usecase'; import { ResetPassword } from './reset-password/reset-password.usecase'; import { RequestForgotPassword } from './request-forgot-password/request-forgot-pasword.usecase'; +import { Verify } from './verify/verify.usecase'; +import { ResendOTP } from './resend-otp/resend-otp.usecase'; import { LoginUserCommand } from './login-user/login-user.command'; +import { OnboardUserCommand } from './onboard-user/onboard-user.command'; import { RegisterUserCommand } from './register-user/register-user.command'; import { ResetPasswordCommand } from './reset-password/reset-password.command'; import { RequestForgotPasswordCommand } from './request-forgot-password/request-forgot-pasword.command'; +import { CreateProject } from 'app/project/usecases'; +import { SaveSampleFile, UpdateImageColumns } from '@shared/usecases'; +import { CreateEnvironment, GenerateUniqueApiKey } from 'app/environment/usecases'; +import { CreateTemplate, UpdateCustomization, UpdateTemplateColumns } from 'app/template/usecases'; + export const USE_CASES = [ - RegisterUser, + Verify, LoginUser, + UpdateUser, + OnboardUser, + RegisterUser, ResetPassword, + CreateProject, + SaveSampleFile, + CreateTemplate, + CreateEnvironment, + UpdateImageColumns, + UpdateCustomization, + GenerateUniqueApiKey, + UpdateTemplateColumns, RequestForgotPassword, + ResendOTP, // ]; -export { RegisterUser, LoginUser, RequestForgotPassword, ResetPassword }; -export { RegisterUserCommand, LoginUserCommand, RequestForgotPasswordCommand, ResetPasswordCommand }; +export { Verify, RegisterUser, LoginUser, RequestForgotPassword, ResetPassword, OnboardUser, ResendOTP, UpdateUser }; +export { + LoginUserCommand, + OnboardUserCommand, + RegisterUserCommand, + ResetPasswordCommand, + RequestForgotPasswordCommand, +}; diff --git a/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts b/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts index b01f745ba..7cd76c150 100644 --- a/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts +++ b/apps/api/src/app/auth/usecases/login-user/login-user.usecase.ts @@ -1,16 +1,20 @@ import * as bcrypt from 'bcryptjs'; import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { EmailService } from '@impler/services'; import { LoginUserCommand } from './login-user.command'; import { AuthService } from '../../services/auth.service'; import { EnvironmentRepository, UserRepository } from '@impler/dal'; +import { EMAIL_SUBJECT, SCREENS } from '@impler/shared'; +import { generateVerificationCode } from '@shared/helpers/common.helper'; @Injectable() export class LoginUser { constructor( private authService: AuthService, private userRepository: UserRepository, - private environmentRepository: EnvironmentRepository + private environmentRepository: EnvironmentRepository, + private emailService: EmailService ) {} async execute(command: LoginUserCommand) { @@ -26,11 +30,37 @@ export class LoginUser { if (!isMatching) { throw new UnauthorizedException(`Incorrect email or password provided.`); } + if (!user.isEmailVerified) { + const verificationCode = generateVerificationCode(); + const emailContents = this.emailService.getEmailContent({ + type: 'VERIFICATION_EMAIL', + data: { + otp: verificationCode, + firstName: user.firstName, + }, + }); + + await this.emailService.sendEmail({ + to: command.email, + subject: EMAIL_SUBJECT.VERIFICATION_CODE, + html: emailContents, + from: process.env.ALERT_EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + + this.userRepository.update( + { _id: user._id }, + { + ...user, + verificationCode, + } + ); + } const apiKey = await this.environmentRepository.getApiKeyForUserId(user._id); return { - showAddProject: !apiKey, + screen: !user.isEmailVerified ? SCREENS.VERIFY : apiKey ? SCREENS.HOME : SCREENS.ONBOARD, token: this.authService.getSignedToken( { _id: user._id, @@ -39,6 +69,7 @@ export class LoginUser { lastName: user.lastName, profilePicture: user.profilePicture, accessToken: apiKey?.apiKey, + isEmailVerified: user.isEmailVerified, }, apiKey?.projectId ), diff --git a/apps/api/src/app/auth/usecases/onboard-user/onboard-user.command.ts b/apps/api/src/app/auth/usecases/onboard-user/onboard-user.command.ts new file mode 100644 index 000000000..f2980bb23 --- /dev/null +++ b/apps/api/src/app/auth/usecases/onboard-user/onboard-user.command.ts @@ -0,0 +1,7 @@ +export class OnboardUserCommand { + _userId: string; + projectName: string; + companySize: string; + role: string; + source: string; +} diff --git a/apps/api/src/app/auth/usecases/onboard-user/onboard-user.usecase.ts b/apps/api/src/app/auth/usecases/onboard-user/onboard-user.usecase.ts new file mode 100644 index 000000000..8cedefec0 --- /dev/null +++ b/apps/api/src/app/auth/usecases/onboard-user/onboard-user.usecase.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { UserRepository } from '@impler/dal'; +import { LEAD_SIGNUP_USING } from '@shared/constants'; +import { OnboardUserCommand } from './onboard-user.command'; +import { LeadService } from '@shared/services/lead.service'; +import { captureException } from '@shared/helpers/common.helper'; +import { CreateProject, CreateProjectCommand } from 'app/project/usecases'; + +@Injectable() +export class OnboardUser { + constructor( + private leadService: LeadService, + private createProject: CreateProject, + private userRepository: UserRepository + ) {} + + async execute(command: OnboardUserCommand) { + const createdProject = await this.createProject.execute( + CreateProjectCommand.create({ + _userId: command._userId, + name: command.projectName, + onboarding: true, + }) + ); + + await this.userRepository.update( + { _id: command._userId }, + { + role: command.role, + companySize: command.companySize, + source: command.source, + } + ); + + const updatedUser = await this.userRepository.findOne({ _id: command._userId }); + if (updatedUser) { + try { + await this.leadService.createLead({ + 'First Name': updatedUser.firstName, + 'Last Name': updatedUser.lastName, + 'Lead Email': updatedUser.email, + 'Lead Source': updatedUser.source, + 'Mentioned Role': updatedUser.role, + 'Signup Method': updatedUser.signupMethod as LEAD_SIGNUP_USING, + 'Company Size': updatedUser.companySize, + }); + } catch (error) { + captureException(error); + } + } + + return createdProject; + } +} diff --git a/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts b/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts index 92e20b883..6d617ede3 100644 --- a/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts +++ b/apps/api/src/app/auth/usecases/register-user/register-user.usecase.ts @@ -2,19 +2,21 @@ import * as bcrypt from 'bcryptjs'; import { Injectable } from '@nestjs/common'; import { UserRepository } from '@impler/dal'; -import { PaymentAPIService } from '@impler/services'; -import { AuthService } from '../../services/auth.service'; + +import { EmailService } from '@impler/services'; +import { LEAD_SIGNUP_USING } from '@shared/constants'; +import { SCREENS, EMAIL_SUBJECT } from '@impler/shared'; +import { AuthService } from 'app/auth/services/auth.service'; import { RegisterUserCommand } from './register-user.command'; +import { generateVerificationCode } from '@shared/helpers/common.helper'; import { UniqueEmailException } from '@shared/exceptions/unique-email.exception'; -import { LeadService } from '@shared/services/lead.service'; @Injectable() export class RegisterUser { constructor( - private userRepository: UserRepository, private authService: AuthService, - private leadService: LeadService, - private paymentAPIService: PaymentAPIService + private emailService: EmailService, + private userRepository: UserRepository ) {} async execute(command: RegisterUserCommand) { @@ -26,37 +28,51 @@ export class RegisterUser { } const passwordHash = await bcrypt.hash(command.password, 10); + const verificationCode = generateVerificationCode(); + const user = await this.userRepository.create({ email: command.email, firstName: command.firstName.toLowerCase(), lastName: command.lastName?.toLowerCase(), password: passwordHash, + signupMethod: LEAD_SIGNUP_USING.EMAIL, + verificationCode, + isEmailVerified: this.emailService.isConnected() ? false : true, }); - await this.leadService.createLead({ - 'First Name': user.firstName, - 'Last Name': user.lastName, - 'Lead Email': user.email, - 'Lead Source': 'Website Signup', - }); - - const userData = { - name: user.firstName + ' ' + user.lastName, - email: user.email, - externalId: user.email, - }; - - await this.paymentAPIService.createUser(userData); - const token = this.authService.getSignedToken({ _id: user._id, email: user.email, firstName: user.firstName, lastName: user.lastName, + isEmailVerified: user.isEmailVerified, }); + if (this.emailService.isConnected()) { + const emailContents = this.emailService.getEmailContent({ + type: 'VERIFICATION_EMAIL', + data: { + otp: verificationCode, + firstName: user.firstName, + }, + }); + + await this.emailService.sendEmail({ + to: command.email, + subject: EMAIL_SUBJECT.VERIFICATION_CODE, + html: emailContents, + from: process.env.ALERT_EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + + return { + screen: SCREENS.VERIFY, + token, + }; + } + return { - showAddProject: true, + screen: SCREENS.ONBOARD, token, }; } diff --git a/apps/api/src/app/auth/usecases/request-forgot-password/request-forgot-pasword.usecase.ts b/apps/api/src/app/auth/usecases/request-forgot-password/request-forgot-pasword.usecase.ts index 475fb5f6a..41369b0e5 100644 --- a/apps/api/src/app/auth/usecases/request-forgot-password/request-forgot-pasword.usecase.ts +++ b/apps/api/src/app/auth/usecases/request-forgot-password/request-forgot-pasword.usecase.ts @@ -5,6 +5,7 @@ import { differenceInHours, differenceInSeconds, parseISO } from 'date-fns'; import { EmailService } from '@impler/services'; import { UserRepository, UserEntity, IUserResetTokenCount } from '@impler/dal'; import { RequestForgotPasswordCommand } from './request-forgot-pasword.command'; +import { EMAIL_SUBJECT } from '@impler/shared'; @Injectable() export class RequestForgotPassword { @@ -39,7 +40,7 @@ export class RequestForgotPassword { from: process.env.EMAIL_FROM, html: resetPasswordContent, senderName: process.env.EMAIL_FROM_NAME, - subject: 'Reset Password | Impler', + subject: EMAIL_SUBJECT.RESET_PASSWORD, }); } diff --git a/apps/api/src/app/auth/usecases/resend-otp/resend-otp.usecase.ts b/apps/api/src/app/auth/usecases/resend-otp/resend-otp.usecase.ts new file mode 100644 index 000000000..ef156e948 --- /dev/null +++ b/apps/api/src/app/auth/usecases/resend-otp/resend-otp.usecase.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { EmailService } from '@impler/services'; +import { UserRepository } from '@impler/dal'; +import { EMAIL_SUBJECT } from '@impler/shared'; +import { generateVerificationCode } from '@shared/helpers/common.helper'; + +@Injectable() +export class ResendOTP { + constructor( + private emailService: EmailService, + private userRepository: UserRepository + ) {} + + async execute(_userId: string) { + const newVerificationCode = generateVerificationCode(); + + const user = await this.userRepository.findOneAndUpdate( + { _id: _userId }, + { $set: { verificationCode: newVerificationCode } } + ); + + if (this.emailService.isConnected()) { + const emailContents = this.emailService.getEmailContent({ + type: 'VERIFICATION_EMAIL', + data: { + otp: newVerificationCode, + firstName: user.firstName, + }, + }); + + await this.emailService.sendEmail({ + to: user.email, + subject: EMAIL_SUBJECT.VERIFICATION_CODE, + html: emailContents, + from: process.env.ALERT_EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + } + + return { success: true }; + } +} diff --git a/apps/api/src/app/auth/usecases/reset-password/reset-password.usecase.ts b/apps/api/src/app/auth/usecases/reset-password/reset-password.usecase.ts index 7e971c8ae..e91b72ddf 100644 --- a/apps/api/src/app/auth/usecases/reset-password/reset-password.usecase.ts +++ b/apps/api/src/app/auth/usecases/reset-password/reset-password.usecase.ts @@ -6,6 +6,7 @@ import { EnvironmentRepository, UserRepository } from '@impler/dal'; import { ResetPasswordCommand } from './reset-password.command'; import { ApiException } from '@shared/exceptions/api.exception'; import { AuthService } from '../../services/auth.service'; +import { SCREENS } from '@impler/shared'; @Injectable() export class ResetPassword { @@ -15,7 +16,7 @@ export class ResetPassword { private environmentRepository: EnvironmentRepository ) {} - async execute(command: ResetPasswordCommand): Promise<{ token: string; showAddProject: boolean }> { + async execute(command: ResetPasswordCommand): Promise<{ token: string; screen: SCREENS }> { const user = await this.userRepository.findUserByToken(command.token); if (!user) { throw new ApiException('Bad token provided'); @@ -46,7 +47,7 @@ export class ResetPassword { const apiKey = this.environmentRepository.getApiKeyForUserId(user._id); return { - showAddProject: !apiKey, + screen: apiKey ? SCREENS.HOME : SCREENS.ONBOARD, token: await this.authService.generateUserToken(user), }; } diff --git a/apps/api/src/app/auth/usecases/update-user/update-user.command.ts b/apps/api/src/app/auth/usecases/update-user/update-user.command.ts new file mode 100644 index 000000000..f6a368480 --- /dev/null +++ b/apps/api/src/app/auth/usecases/update-user/update-user.command.ts @@ -0,0 +1,3 @@ +export class UpdateUserCommand { + email?: string; +} diff --git a/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts b/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts new file mode 100644 index 000000000..244b62249 --- /dev/null +++ b/apps/api/src/app/auth/usecases/update-user/update-user.usecase.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { UserRepository } from '@impler/dal'; +import { EMAIL_SUBJECT } from '@impler/shared'; +import { EmailService } from '@impler/services'; +import { UpdateUserCommand } from './update-user.command'; +import { AuthService } from 'app/auth/services/auth.service'; +import { generateVerificationCode } from '@shared/helpers/common.helper'; +import { UniqueEmailException } from '@shared/exceptions/unique-email.exception'; + +@Injectable() +export class UpdateUser { + constructor( + private authService: AuthService, + private emailService: EmailService, + private userRepository: UserRepository + ) {} + + async execute(_userId: string, data: UpdateUserCommand) { + if (data.email) { + const userWithEmail = await this.userRepository.findOne({ + email: data.email, + _id: { $ne: _userId }, + }); + if (userWithEmail) { + throw new UniqueEmailException(); + } + + const newVerificationCode = generateVerificationCode(); + + const user = await this.userRepository.findOneAndUpdate( + { _id: _userId }, + { $set: { verificationCode: newVerificationCode, ...data } } + ); + + if (this.emailService.isConnected() && data.email) { + // if email updated, send verification code + const emailContents = this.emailService.getEmailContent({ + type: 'VERIFICATION_EMAIL', + data: { + otp: newVerificationCode, + firstName: user.firstName, + }, + }); + + await this.emailService.sendEmail({ + to: user.email, + subject: EMAIL_SUBJECT.VERIFICATION_CODE, + html: emailContents, + from: process.env.ALERT_EMAIL_FROM, + senderName: process.env.EMAIL_FROM_NAME, + }); + } + + const token = this.authService.getSignedToken({ + _id: user._id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + isEmailVerified: user.isEmailVerified, + }); + + return { success: true, token }; + } + + return { success: true }; + } +} diff --git a/apps/api/src/app/auth/usecases/verify/verify.command.ts b/apps/api/src/app/auth/usecases/verify/verify.command.ts new file mode 100644 index 000000000..35856f585 --- /dev/null +++ b/apps/api/src/app/auth/usecases/verify/verify.command.ts @@ -0,0 +1,3 @@ +export class VerifyCommand { + code: string; +} diff --git a/apps/api/src/app/auth/usecases/verify/verify.usecase.ts b/apps/api/src/app/auth/usecases/verify/verify.usecase.ts new file mode 100644 index 000000000..367a359ad --- /dev/null +++ b/apps/api/src/app/auth/usecases/verify/verify.usecase.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; + +import { SCREENS } from '@impler/shared'; +import { VerifyCommand } from './verify.command'; +import { PaymentAPIService } from '@impler/services'; +import { AuthService } from 'app/auth/services/auth.service'; +import { captureException } from '@shared/helpers/common.helper'; +import { UserRepository, EnvironmentRepository } from '@impler/dal'; +import { InvalidVerificationCodeException } from '@shared/exceptions/otp-verification.exception'; + +@Injectable() +export class Verify { + constructor( + private authService: AuthService, + private userRepository: UserRepository, + private paymentAPIService: PaymentAPIService, + private environmentRepository: EnvironmentRepository + ) {} + + async execute(_userId: string, command: VerifyCommand) { + const user = await this.userRepository.findOne({ + _id: _userId, + verificationCode: command.code, + }); + + if (!user) { + throw new InvalidVerificationCodeException(); + } + + const userData = { + name: user.firstName + ' ' + user.lastName, + email: user.email, + externalId: user.email, + }; + + try { + await this.paymentAPIService.createUser(userData); + } catch (error) { + captureException(error); + } + + await this.userRepository.findOneAndUpdate( + { + _id: _userId, + }, + { + $set: { + isEmailVerified: true, + }, + } + ); + const apiKey = await this.environmentRepository.getApiKeyForUserId(user._id); + + const token = this.authService.getSignedToken({ + _id: user._id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + isEmailVerified: true, + accessToken: apiKey?.apiKey, + }); + + return { + token, + screen: apiKey ? SCREENS.HOME : SCREENS.ONBOARD, + }; + } +} diff --git a/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts b/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts index dc697bcf6..2f6634d40 100644 --- a/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts +++ b/apps/api/src/app/environment/usecases/regenerate-api-key/regenerate-api-key.usecase.ts @@ -7,9 +7,9 @@ import { GenerateUniqueApiKey } from '../generate-api-key/generate-api-key.useca @Injectable() export class RegenerateAPIKey { constructor( + private authService: AuthService, private generateUniqueApiKey: GenerateUniqueApiKey, - private environmentRepository: EnvironmentRepository, - private authService: AuthService + private environmentRepository: EnvironmentRepository ) {} async execute(userInfo: IJwtPayload) { @@ -32,6 +32,7 @@ export class RegenerateAPIKey { firstName: userInfo.firstName, lastName: userInfo.lastName, profilePicture: userInfo.profilePicture, + isEmailVerified: userInfo.isEmailVerified, accessToken, }, userInfo._projectId diff --git a/apps/api/src/app/project/project.controller.ts b/apps/api/src/app/project/project.controller.ts index 13b290711..8c7fd0810 100644 --- a/apps/api/src/app/project/project.controller.ts +++ b/apps/api/src/app/project/project.controller.ts @@ -128,6 +128,7 @@ export class ProjectController { lastName: user.lastName, email: user.email, profilePicture: user.profilePicture, + isEmailVerified: user.isEmailVerified, accessToken: projectWithEnvironment.environment.apiKeys[0].key, }, projectWithEnvironment.project._id @@ -156,6 +157,7 @@ export class ProjectController { firstName: user.firstName, lastName: user.lastName, email: user.email, + isEmailVerified: user.isEmailVerified, profilePicture: user.profilePicture, accessToken: projectEnvironment.apiKeys[0].key, }, diff --git a/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts b/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts index 09e8a55c3..557a2fcf0 100644 --- a/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts +++ b/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts @@ -3,6 +3,7 @@ import { Writable } from 'stream'; import { Injectable, BadRequestException, InternalServerErrorException } from '@nestjs/common'; import { APIMessages } from '@shared/constants'; +import { EMAIL_SUBJECT } from '@impler/shared'; import { BaseReview } from './base-review.usecase'; import { BATCH_LIMIT } from '@shared/services/sandbox'; import { StorageService, PaymentAPIService, EmailService } from '@impler/services'; @@ -120,7 +121,7 @@ export class DoReview extends BaseReview { }, }); errorEmailContents.push({ - subject: `🛑 Encountered error while executing validation code in ${ + subject: `${EMAIL_SUBJECT.ERROR_EXECUTING_VALIDATION_CODE} ${ (uploadInfo._templateId as unknown as TemplateEntity).name }`, content: emailContent, diff --git a/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts b/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts index 8909a2135..6e97d2e87 100644 --- a/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts +++ b/apps/api/src/app/review/usecases/do-review/re-review-data.usecase.ts @@ -7,6 +7,7 @@ import { ColumnDelimiterEnum, ColumnTypesEnum, ITemplateSchemaItem, UploadStatus import { UploadRepository, ValidatorRepository, DalService, TemplateEntity } from '@impler/dal'; import { APIMessages } from '@shared/constants'; +import { EMAIL_SUBJECT } from '@impler/shared'; import { EmailService } from '@impler/services'; import { BATCH_LIMIT } from '@shared/services/sandbox'; import { BaseReview } from './base-review.usecase'; @@ -287,7 +288,7 @@ export class DoReReview extends BaseReview { }, }); errorEmailContents.push({ - subject: `🛑 Encountered error while executing validation code in ${name}`, + subject: `${EMAIL_SUBJECT.ERROR_EXECUTING_VALIDATION_CODE} ${name}`, content: emailContent, }); }, diff --git a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts index 5ed497fd5..c502d21e2 100644 --- a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts +++ b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts @@ -41,19 +41,23 @@ export class StartProcess { }); } - // if template destination has callbackUrl then start sending data to the callbackUrl - if (destination) { + // if destination is frontend or not defined then complete the upload process + if ( + !destination || + (uploadInfo._templateId as unknown as TemplateEntity).destination === DestinationsEnum.FRONTEND + ) { uploadInfo = await this.uploadRepository.findOneAndUpdate( { _id: _uploadId }, - { status: UploadStatusEnum.PROCESSING } + { status: UploadStatusEnum.COMPLETED } ); } else { - // else complete the upload process + // if template destination has callbackUrl then start sending data to the callbackUrl uploadInfo = await this.uploadRepository.findOneAndUpdate( { _id: _uploadId }, - { status: UploadStatusEnum.COMPLETED } + { status: UploadStatusEnum.PROCESSING } ); } + this.queueService.publishToQueue(QueuesEnum.END_IMPORT, { uploadId: _uploadId, destination: destination, diff --git a/apps/api/src/app/shared/constants.ts b/apps/api/src/app/shared/constants.ts index f4a8bd86d..bcfbf7fe8 100644 --- a/apps/api/src/app/shared/constants.ts +++ b/apps/api/src/app/shared/constants.ts @@ -36,6 +36,7 @@ export const APIMessages = { ERROR_ACCESSING_FEATURE: { IMAGE_UPLOAD: 'You do not have access to Image Upload Functionality.', }, + INVALID_VERIFICATION_CODE: 'Code you entered is invalid! Please try again or request new verification code!', }; export const CONSTANTS = { @@ -82,3 +83,9 @@ export const VARIABLES = { export const DATE_FORMATS = { COMMON: 'DD MMM YYYY', }; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum LEAD_SIGNUP_USING { + GITHUB = 'Github', + EMAIL = 'Email', +} diff --git a/apps/api/src/app/shared/exceptions/file-size-limit.exception.ts b/apps/api/src/app/shared/exceptions/file-size-limit.exception.ts new file mode 100644 index 000000000..f49ab0272 --- /dev/null +++ b/apps/api/src/app/shared/exceptions/file-size-limit.exception.ts @@ -0,0 +1,24 @@ +import { BadRequestException } from '@nestjs/common'; +import { numberFormatter } from '@impler/shared'; + +export class FileSizeException extends BadRequestException { + constructor({ + columns, + recordsToSplit, + rows, + files, + isExcel, + }: { + rows: number; + columns: number; + recordsToSplit: number; + files: number; + isExcel?: boolean; + }) { + super( + `File too large: ${numberFormatter(rows)} rows and ${numberFormatter(columns)} columns detected. ` + + `Please split into ${files} ${isExcel ? 'Excel' : 'CSV'} file${files > 1 ? 's' : ''} ` + + `of ${numberFormatter(recordsToSplit)} rows or less each and upload separately!` + ); + } +} diff --git a/apps/api/src/app/shared/exceptions/otp-verification.exception.ts b/apps/api/src/app/shared/exceptions/otp-verification.exception.ts new file mode 100644 index 000000000..b3cce09ea --- /dev/null +++ b/apps/api/src/app/shared/exceptions/otp-verification.exception.ts @@ -0,0 +1,15 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { APIMessages } from '../constants'; + +export class InvalidVerificationCodeException extends HttpException { + constructor() { + super( + { + message: APIMessages.INVALID_VERIFICATION_CODE, + error: 'OTPVerificationFalid', + statusCode: HttpStatus.BAD_REQUEST, + }, + HttpStatus.BAD_REQUEST + ); + } +} diff --git a/apps/api/src/app/shared/helpers/common.helper.ts b/apps/api/src/app/shared/helpers/common.helper.ts index 0c339737f..f1769d788 100644 --- a/apps/api/src/app/shared/helpers/common.helper.ts +++ b/apps/api/src/app/shared/helpers/common.helper.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/node'; import { BadRequestException } from '@nestjs/common'; import { APIMessages } from '../constants'; import { PaginationResult, Defaults, FileMimeTypesEnum } from '@impler/shared'; @@ -65,3 +66,20 @@ export function getAssetMimeType(name: string): string { else if (name.endsWith('.webp')) return FileMimeTypesEnum.WEBP; throw new Error('Unsupported file type'); } + +export function generateVerificationCode(): string { + let otp = ''; + + for (let i = 0; i < 2; i++) { + const group = Math.floor(Math.random() * 900) + 100; + otp += group.toString(); + } + + return otp; +} + +export function captureException(error: any) { + if (Sentry.isInitialized()) { + Sentry.captureException(error); + } else console.error(error); +} diff --git a/apps/api/src/app/shared/services/file/file.service.ts b/apps/api/src/app/shared/services/file/file.service.ts index e584fd2b1..ad5cbb220 100644 --- a/apps/api/src/app/shared/services/file/file.service.ts +++ b/apps/api/src/app/shared/services/file/file.service.ts @@ -69,7 +69,7 @@ export class ExcelFileService { return columnName.reverse().join(''); } - async getExcelFileForHeadings(headings: IExcelFileHeading[], data?: Record[]): Promise { + async getExcelFileForHeadings(headings: IExcelFileHeading[], data?: string): Promise { const currentDir = cwd(); const isMultiSelect = headings.some( (heading) => heading.type === ColumnTypesEnum.SELECT && heading.allowMultiSelect @@ -78,15 +78,17 @@ export class ExcelFileService { `${currentDir}/src/config/${isMultiSelect ? 'Excel Multi Select Template.xlsm' : 'Excel Template.xlsx'}` ); const worksheet = workbook.sheet('Data'); + const multiSelectHeadings = {}; headings.forEach((heading, index) => { const columnName = this.getExcelColumnNameFromIndex(index + 1); const columnHeadingCellName = columnName + '1'; - if (heading.type === ColumnTypesEnum.SELECT && heading.allowMultiSelect) + if (heading.type === ColumnTypesEnum.SELECT && heading.allowMultiSelect) { worksheet .cell(columnHeadingCellName) .value(heading.key + '#MULTI' + '#' + (heading.delimiter || ColumnDelimiterEnum.COMMA)); - else worksheet.cell(columnHeadingCellName).value(heading.key); + multiSelectHeadings[heading.key] = heading.delimiter || ColumnDelimiterEnum.COMMA; + } else worksheet.cell(columnHeadingCellName).value(heading.key); worksheet.column(columnName).style('numberFormat', '@'); }); @@ -114,9 +116,20 @@ export class ExcelFileService { }); const headingNames = headings.map((heading) => heading.key); const endColumnPosition = this.getExcelColumnNameFromIndex(headings.length + 1); - if (Array.isArray(data) && data.length > 0) { - const rows: string[][] = data.reduce((acc: string[][], rowItem: Record) => { - acc.push(headingNames.map((headingKey) => rowItem[headingKey])); + + let parsedData = []; + try { + if (data) parsedData = JSON.parse(data); + } catch (error) {} + if (Array.isArray(parsedData) && parsedData.length > 0) { + const rows: string[][] = parsedData.reduce((acc: string[][], rowItem: Record) => { + acc.push( + headingNames.map((headingKey) => + multiSelectHeadings[headingKey] && Array.isArray(rowItem[headingKey]) + ? rowItem[headingKey].join(multiSelectHeadings[headingKey]) + : rowItem[headingKey] + ) + ); return acc; }, []); @@ -138,9 +151,78 @@ export class ExcelFileService { } }); } + getExcelRowsColumnsCount(file: Express.Multer.File, sheetName?: string): Promise<{ rows: number; columns: number }> { + return new Promise(async (resolve, reject) => { + try { + const wb = XLSX.read(file.buffer); + const ws = sheetName && wb.SheetNames.includes(sheetName) ? wb.Sheets[sheetName] : wb.Sheets[wb.SheetNames[0]]; + const range = ws['!ref']; + const regex = /([A-Z]+)(\d+):([A-Z]+)(\d+)/; + const match = range.match(regex); + + if (!match) reject(new InvalidFileException()); + + const [, startCol, startRow, endCol, endRow] = match; + + function columnToNumber(col: string) { + let num = 0; + for (let i = 0; i < col.length; i++) { + num = num * 26 + (col.charCodeAt(i) - 64); + } + + return num; + } + + const columns = columnToNumber(endCol) - columnToNumber(startCol) + 1; + const rows = parseInt(endRow) - parseInt(startRow) + 1; + + resolve({ + columns, + rows, + }); + } catch (error) { + reject(error); + } + }); + } } export class CSVFileService2 { + getCSVMetaInfo(file: string | Express.Multer.File, options?: ParseConfig) { + return new Promise<{ rows: number; columns: number }>((resolve, reject) => { + let fileContent = ''; + if (typeof file === 'string') { + fileContent = file; + } else { + fileContent = file.buffer.toString(FileEncodingsEnum.CSV); + } + let rows = 0; + let columns = 0; + + parse(fileContent, { + ...(options || {}), + dynamicTyping: false, + skipEmptyLines: true, + step: function (results) { + rows++; + if (Array.isArray(results.data)) { + columns = results.data.length; + } + }, + complete: function () { + resolve({ rows, columns }); + }, + error: (error) => { + if (error.message.includes('Parse Error')) { + reject(new InvalidFileException()); + } else { + reject(error); + } + }, + }); + }); + } + getFileHeaders(file: string | Express.Multer.File, options?: ParseConfig): Promise { return new Promise((resolve, reject) => { let fileContent = ''; diff --git a/apps/api/src/app/shared/services/lead.service.ts b/apps/api/src/app/shared/services/lead.service.ts index e66bd34d0..0ebbed82f 100644 --- a/apps/api/src/app/shared/services/lead.service.ts +++ b/apps/api/src/app/shared/services/lead.service.ts @@ -1,11 +1,16 @@ -import { Injectable } from '@nestjs/common'; import axios from 'axios'; +import { Injectable } from '@nestjs/common'; +import { LEAD_SIGNUP_USING } from '@shared/constants'; +import { captureException } from '@shared/helpers/common.helper'; interface ILeadInformation { 'First Name': string; 'Last Name': string; 'Lead Email': string; - 'Lead Source': 'Website Signup' | 'Github Signup'; + 'Signup Method': LEAD_SIGNUP_USING; + 'Mentioned Role': string; + 'Lead Source': string; + 'Company Size': string; } @Injectable() @@ -68,22 +73,30 @@ export class LeadService { public async createLead(data: ILeadInformation): Promise { const maAccessToken = await this.getMaAccessToken(); if (maAccessToken) { + const leadData = JSON.stringify({ + 'First Name': data['First Name'], + 'Last Name': data['Last Name'], + 'Lead Email': data['Lead Email'], + }); // Add Lead to marketing automation - const maUrl = `https://marketingautomation.zoho.com/api/v1/json/listsubscribe?listkey=${ - process.env.LEAD_LIST_KEY - }&leadinfo=${JSON.stringify(data)}&topic_id=${process.env.LEAD_TOPIC_ID}`; + // eslint-disable-next-line max-len + const maUrl = `https://marketingautomation.zoho.com/api/v1/json/listsubscribe?listkey=${process.env.LEAD_LIST_KEY}&leadinfo=${leadData}&topic_id=${process.env.LEAD_TOPIC_ID}`; if (this.log) console.log(maUrl); - const maResponse = await axios.post( - maUrl, - {}, - { - headers: { - Authorization: `Zoho-oauthtoken ${maAccessToken}`, - }, - } - ); - if (this.log) console.log('Lead created', maResponse.data); + try { + const maResponse = await axios.post( + maUrl, + {}, + { + headers: { + Authorization: `Zoho-oauthtoken ${maAccessToken}`, + }, + } + ); + if (this.log) console.log('Lead created', maResponse.data); + } catch (error) { + captureException(error); + } } const crmAccessToken = await this.getCRMAccessToken(); if (crmAccessToken) { @@ -91,25 +104,32 @@ export class LeadService { const crmUrl = `https://www.zohoapis.com/crm/v6/Leads`; if (this.log) console.log(crmUrl); - const crmResponse = await axios.post( - crmUrl, - { - data: [ - { - Last_Name: data['Last Name'], - First_Name: data['First Name'], - Email: data['Lead Email'], - Lead_Source: data['Lead Source'], - }, - ], - }, - { - headers: { - Authorization: `Zoho-oauthtoken ${crmAccessToken}`, + try { + const crmResponse = await axios.post( + crmUrl, + { + data: [ + { + Last_Name: data['Last Name'], + First_Name: data['First Name'], + Email: data['Lead Email'], + Lead_Source: data['Lead Source'], + Signup_Method: data['Signup Method'], + Mentioned_Role: data['Mentioned Role'], + Company_Size: [data['Company Size']], + }, + ], }, - } - ); - if (this.log) console.log('CRM LEad created', crmResponse.data); + { + headers: { + Authorization: `Zoho-oauthtoken ${crmAccessToken}`, + }, + } + ); + if (this.log) console.log('CRM LEad created', crmResponse.data); + } catch (error) { + captureException(error); + } } } } diff --git a/apps/api/src/app/template/dtos/download-sample-request.dto.ts b/apps/api/src/app/template/dtos/download-sample-request.dto.ts index 9589ab69b..d080a0f90 100644 --- a/apps/api/src/app/template/dtos/download-sample-request.dto.ts +++ b/apps/api/src/app/template/dtos/download-sample-request.dto.ts @@ -1,10 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsArray, IsJSON } from 'class-validator'; +import { IsOptional, IsJSON } from 'class-validator'; export class DownloadSampleDto { - @IsArray() + @IsJSON() @IsOptional() - data: Record[]; + data?: string; @IsJSON() @IsOptional() diff --git a/apps/api/src/app/template/usecases/download-sample/download-sample.command.ts b/apps/api/src/app/template/usecases/download-sample/download-sample.command.ts index dc753c7bf..3d51d57ef 100644 --- a/apps/api/src/app/template/usecases/download-sample/download-sample.command.ts +++ b/apps/api/src/app/template/usecases/download-sample/download-sample.command.ts @@ -1,5 +1,5 @@ export class DownloadSampleDataCommand { - data: Record[]; + data?: string; schema?: string; importId: string; imageSchema?: string; diff --git a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts index d2ee732ad..01a1d02eb 100644 --- a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts +++ b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts @@ -25,6 +25,7 @@ import { AddUploadEntryCommand } from './add-upload-entry.command'; import { MakeUploadEntryCommand } from './make-upload-entry.command'; import { FileParseException } from '@shared/exceptions/file-parse-issue.exception'; import { CSVFileService2, ExcelFileService } from '@shared/services/file'; +import { FileSizeException } from '@shared/exceptions/file-size-limit.exception'; @Injectable() export class MakeUploadEntry { @@ -50,6 +51,7 @@ export class MakeUploadEntry { imageSchema, selectedSheetName, }: MakeUploadEntryCommand) { + const csvFileService = new CSVFileService2(); const fileOriginalName = file.originalname; let csvFile: string | Express.Multer.File = file; if ( @@ -59,11 +61,18 @@ export class MakeUploadEntry { ) { try { const fileService = new ExcelFileService(); + const opts = await fileService.getExcelRowsColumnsCount(file, selectedSheetName); + this.analyzeLargeFile(opts, true); csvFile = await fileService.convertToCsv(file, selectedSheetName); } catch (error) { + if (error instanceof FileSizeException) { + throw error; + } throw new FileParseException(); } } else if (file.mimetype === FileMimeTypesEnum.CSV) { + const opts = await csvFileService.getCSVMetaInfo(file); + this.analyzeLargeFile(opts, false); csvFile = file; } else { throw new Error('Invalid file type'); @@ -134,8 +143,7 @@ export class MakeUploadEntry { customRecordFormat = defaultCustomization?.recordFormat; } - const fileService = new CSVFileService2(); - const fileHeadings = await fileService.getFileHeaders(csvFile); + const fileHeadings = await csvFileService.getFileHeaders(csvFile); const uploadId = importId || this.commonRepository.generateMongoId().toString(); const fileEntity = await this.makeFileEntry(uploadId, csvFile, fileOriginalName); @@ -166,7 +174,29 @@ export class MakeUploadEntry { originalFileType: file.mimetype, }); } + roundToNiceNumber(num: number) { + const niceNumbers = [500, 1000, 5000, 10000, 50000, 100000, 500000, 1000000]; + + return niceNumbers.reduce((prev, curr) => (Math.abs(curr - num) < Math.abs(prev - num) ? curr : prev)); + } + analyzeLargeFile(fileInfo: { rows: number; columns: number }, isExcel?: boolean, maxDataPoints = 5000000) { + const { columns, rows } = fileInfo; + const dataPoints = columns * rows; + + if (dataPoints > maxDataPoints) { + let suggestedChunkSize = Math.floor(maxDataPoints / columns); + suggestedChunkSize = this.roundToNiceNumber(suggestedChunkSize); + const numberOfChunks = Math.ceil(rows / suggestedChunkSize); + throw new FileSizeException({ + rows, + isExcel, + columns, + files: numberOfChunks, + recordsToSplit: suggestedChunkSize, + }); + } + } private async makeFileEntry( uploadId: string, file: string | Express.Multer.File, diff --git a/apps/api/src/migrations/verify-user/verify-user.migration.ts b/apps/api/src/migrations/verify-user/verify-user.migration.ts new file mode 100644 index 000000000..095f04f52 --- /dev/null +++ b/apps/api/src/migrations/verify-user/verify-user.migration.ts @@ -0,0 +1,25 @@ +import '../../config'; +import { AppModule } from '../../app.module'; + +import { NestFactory } from '@nestjs/core'; +import { UserRepository } from '@impler/dal'; + +export async function run() { + // eslint-disable-next-line no-console + console.log('start migration - verify users'); + + // Init the mongodb connection + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + const userRepository = new UserRepository(); + await userRepository.update({}, { isEmailVerified: true }); + + // eslint-disable-next-line no-console + console.log('end migration - verify users'); + + app.close(); + process.exit(0); +} +run(); diff --git a/apps/queue-manager/package.json b/apps/queue-manager/package.json index f01e03fb5..e37a484f5 100644 --- a/apps/queue-manager/package.json +++ b/apps/queue-manager/package.json @@ -1,6 +1,6 @@ { "name": "@impler/queue-manager", - "version": "0.23.1", + "version": "0.24.0", "author": "implerhq", "license": "MIT", "private": true, @@ -16,9 +16,9 @@ "lint:fix": "pnpm lint -- --fix" }, "dependencies": { - "@impler/dal": "^0.23.1", - "@impler/services": "^0.23.1", - "@impler/shared": "^0.23.1", + "@impler/dal": "^0.24.0", + "@impler/services": "^0.24.0", + "@impler/shared": "^0.24.0", "@sentry/node": "^7.112.2", "axios": "1.6.2", "dotenv": "^16.0.2", diff --git a/apps/queue-manager/src/.example.env b/apps/queue-manager/src/.example.env index a777b901d..6bd45d8a5 100644 --- a/apps/queue-manager/src/.example.env +++ b/apps/queue-manager/src/.example.env @@ -1,5 +1,5 @@ NODE_ENV=local -API_BASE_URL=http://localhost:4200 +API_ROOT_URL=http://localhost:4200 RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 MONGO_URL=mongodb://localhost:27017/impler-db diff --git a/apps/queue-manager/src/consumers/send-bubble-data.consumer.ts b/apps/queue-manager/src/consumers/send-bubble-data.consumer.ts index f55fb71eb..42a64985e 100644 --- a/apps/queue-manager/src/consumers/send-bubble-data.consumer.ts +++ b/apps/queue-manager/src/consumers/send-bubble-data.consumer.ts @@ -7,7 +7,9 @@ import { ITemplateSchemaItem, replaceVariablesInObject, StatusEnum, + EMAIL_SUBJECT, } from '@impler/shared'; + import { BubbleBaseService, EmailService, FileNameService, StorageService } from '@impler/services'; import { UploadRepository, @@ -197,7 +199,7 @@ export class SendBubbleDataConsumer extends BaseConsumer { await this.emailService.sendEmail({ to: userEmail, - subject: `🛑 Encountered error while sending data to Bubble in ${importName}`, + subject: `${EMAIL_SUBJECT.ERROR_SENDING_BUBBLE_DATA} ${importName}`, html: emailContents, from: process.env.ALERT_EMAIL_FROM, senderName: process.env.EMAIL_FROM_NAME, diff --git a/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts b/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts index 4aa955ecb..ce89779a9 100644 --- a/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts +++ b/apps/queue-manager/src/consumers/send-webhook-data.consumer.ts @@ -9,6 +9,7 @@ import { ColumnTypesEnum, ColumnDelimiterEnum, StatusEnum, + EMAIL_SUBJECT, } from '@impler/shared'; import { FileNameService, StorageService, EmailService } from '@impler/services'; import { @@ -135,7 +136,7 @@ export class SendWebhookDataConsumer extends BaseConsumer { if (imageHeadings?.length > 0) imageHeadings.forEach((heading) => { obj.record[heading] = obj.record[heading] - ? `${process.env.API_BASE_URL}/v1/upload/${uploadId}/asset/${obj.record[heading]}` + ? `${process.env.API_ROOT_URL}/v1/upload/${uploadId}/asset/${obj.record[heading]}` : ''; }); @@ -226,7 +227,7 @@ export class SendWebhookDataConsumer extends BaseConsumer { await this.emailService.sendEmail({ to: userEmail, - subject: `🛑 Encountered error while sending webhook data in ${importName}`, + subject: `${EMAIL_SUBJECT.ERROR_SENDING_WEBHOOK_DATA} ${importName}`, html: emailContents, from: process.env.ALERT_EMAIL_FROM, senderName: process.env.EMAIL_FROM_NAME, diff --git a/apps/queue-manager/src/types/env.d.ts b/apps/queue-manager/src/types/env.d.ts index 990b0f490..8211238e5 100644 --- a/apps/queue-manager/src/types/env.d.ts +++ b/apps/queue-manager/src/types/env.d.ts @@ -3,7 +3,7 @@ declare namespace NodeJS { export interface ProcessEnv { MONGO_URL: string; RABBITMQ_CONN_URL: string; - API_BASE_URL: string; + API_ROOT_URL: string; S3_LOCAL_STACK: string; S3_REGION: string; S3_BUCKET_NAME: string; diff --git a/apps/web/assets/icons/Edit.icon.tsx b/apps/web/assets/icons/Edit.icon.tsx index cbea44b5c..69b1be938 100644 --- a/apps/web/assets/icons/Edit.icon.tsx +++ b/apps/web/assets/icons/Edit.icon.tsx @@ -1,9 +1,10 @@ import { IconType } from '@types'; import { IconSizes } from 'config'; -export const EditIcon = ({ size = 'sm', color }: IconType) => { +export const EditIcon = ({ size = 'sm', color, style }: IconType) => { return ( { + return ( + + + + + + ); +}; diff --git a/apps/web/components/home/PlanDetails/PlanDetails.tsx b/apps/web/components/home/PlanDetails/PlanDetails.tsx index 2f3c89edd..8e7c74e9b 100644 --- a/apps/web/components/home/PlanDetails/PlanDetails.tsx +++ b/apps/web/components/home/PlanDetails/PlanDetails.tsx @@ -5,13 +5,13 @@ import { modals } from '@mantine/modals'; import { Title, Text, Flex, Button, Skeleton, Stack } from '@mantine/core'; import { useApp } from '@hooks/useApp'; +import { numberFormatter } from '@impler/shared'; import { SelectCardModal } from '@components/settings'; import { usePlanDetails } from '@hooks/usePlanDetails'; +import TooltipLink from '@components/TooltipLink/TooltipLink'; import { PlansModal } from '@components/UpgradePlan/PlansModal'; -import { CONSTANTS, MODAL_KEYS, ROUTES, colors, DOCUMENTATION_REFERENCE_LINKS } from '@config'; -import { numberFormatter } from '@impler/shared/dist/utils/helpers'; import { ConfirmationModal } from '@components/ConfirmationModal'; -import TooltipLink from '@components/TooltipLink/TooltipLink'; +import { CONSTANTS, MODAL_KEYS, ROUTES, colors, DOCUMENTATION_REFERENCE_LINKS } from '@config'; export function PlanDetails() { const router = useRouter(); diff --git a/apps/web/components/imports/forms/CreateImportForm.tsx b/apps/web/components/imports/forms/CreateImportForm.tsx index 407dbb929..bb49897a4 100644 --- a/apps/web/components/imports/forms/CreateImportForm.tsx +++ b/apps/web/components/imports/forms/CreateImportForm.tsx @@ -5,7 +5,7 @@ import { Stack, TextInput as Input, FocusTrap } from '@mantine/core'; import { Button } from '@ui/button'; interface CreateImportFormProps { - onSubmit: (data: ICreateTemplateData) => void; + onSubmit: (data: IUpdateTemplateData) => void; } export function CreateImportForm({ onSubmit }: CreateImportFormProps) { @@ -14,7 +14,7 @@ export function CreateImportForm({ onSubmit }: CreateImportFormProps) { register, handleSubmit, formState: { errors }, - } = useForm(); + } = useForm(); return ( diff --git a/apps/web/components/signin/CreateProjectForm.tsx b/apps/web/components/signin/CreateProjectForm.tsx deleted file mode 100644 index 18fcef2f3..000000000 --- a/apps/web/components/signin/CreateProjectForm.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Image from 'next/image'; -import { useRouter } from 'next/router'; -import { useForm } from 'react-hook-form'; -import { Title, Stack, TextInput as Input } from '@mantine/core'; -import { useMutation } from '@tanstack/react-query'; - -import { Button } from '@ui/button'; - -import { commonApi } from '@libs/api'; -import { track } from '@libs/amplitude'; -import { API_KEYS, VARIABLES } from '@config'; -import DarkLogo from '@assets/images/logo-dark.png'; -import { IProjectPayload, IErrorObject, IEnvironmentData } from '@impler/shared'; -import { useAppState } from 'store/app.context'; - -export default function CreateProjectForm() { - const { push } = useRouter(); - const { profileInfo, setProfileInfo } = useAppState(); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm(); - const { mutate: createProject, isLoading: isCreateProjectLoading } = useMutation< - { project: IProjectPayload; environment: IEnvironmentData }, - IErrorObject, - ICreateProjectData, - string[] - >( - [API_KEYS.PROJECT_CREATE], - (data) => commonApi(API_KEYS.PROJECT_CREATE as any, { body: { ...data, onboarding: true } }), - { - onSuccess: (data) => { - if (profileInfo) { - setProfileInfo({ - ...profileInfo, - _projectId: data.project._id, - accessToken: data.environment.apiKeys[VARIABLES.ZERO].key, - }); - } - track({ - name: 'PROJECT CREATE', - properties: { - duringOnboard: true, - }, - }); - push('/'); - }, - } - ); - - const onProjectFormSubmit = (data: ICreateProjectData) => { - createProject(data); - }; - - return ( - <> - - - Let's Create your first project - -
- - - - -
- - ); -} diff --git a/apps/web/components/signin/OnboardUserForm.tsx b/apps/web/components/signin/OnboardUserForm.tsx new file mode 100644 index 000000000..ddec36d4a --- /dev/null +++ b/apps/web/components/signin/OnboardUserForm.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { Title, Text, Stack, TextInput, Select, Radio, Group, Container, Flex, Box, FocusTrap } from '@mantine/core'; + +import { Button } from '@ui/button'; +import { useApp } from '@hooks/useApp'; +import { colors, COMPANY_SIZES, HOW_HEARD_ABOUT_US, ROLES } from '@config'; +import { useOnboardUserProjectForm } from '@hooks/useOnboardUserProjectForm'; + +export function OnboardUserForm() { + const { profile } = useApp(); + const [role] = useState(ROLES); + const [about, setAbout] = useState(HOW_HEARD_ABOUT_US); + + const { onboardUser, isUserOnboardLoading } = useOnboardUserProjectForm(); + + const { + handleSubmit, + formState: { errors }, + control, + } = useForm(); + + const onSubmit = (formData: IOnboardUserData) => { + onboardUser(formData); + }; + + return ( + + + + 👋 + Welcome, {profile?.firstName} + + + + We just need to confirm a couple of details, it‘s only take a minute. + +
+ + + value.trim().length > 0 || 'Project name cannot be empty or contain only spaces', + }, + }} + render={({ field }) => ( + + )} + /> + + ( + field.onChange(value as string)} + > + + {COMPANY_SIZES.map((companySize) => ( + ({ + border: `1px solid ${colors.darkGrey}`, + })} + > + + + ))} + + + )} + /> + ( + + )} + /> + + ( + + +
+
+ ); +} diff --git a/apps/web/config/constants.config.ts b/apps/web/config/constants.config.ts index 447fc5b73..449bd3589 100644 --- a/apps/web/config/constants.config.ts +++ b/apps/web/config/constants.config.ts @@ -1,5 +1,3 @@ -import { TemplateModeEnum } from '@impler/shared'; - export const CONSTANTS = { PLAN_CODE_QUERY_KEY: 'plan_code', GITHUB_LOGIN_URL: '/v1/auth/github', @@ -59,6 +57,11 @@ export const MODAL_TITLES = { }; export const API_KEYS = { + RESEND_OTP: 'RESEND_OTP', + VERIFY_EMAIL: 'VERIFY_EMAIL', + + ONBOARD_USER: 'ONBOARD_USER', + CHECKOUT: 'CHECKOUT', APPLY_COUPON_CODE: 'APPLY_COUPON_CODE', @@ -113,12 +116,14 @@ export const API_KEYS = { ACTIVITY_SUMMARY: 'ACTIVITY_SUMMARY', ME: 'ME', + UPDATE_ME_INFO: 'UPDATE_ME_INFO', REGENERATE: 'REGENERATE', IMPORT_COUNT: 'IMPORT_COUNT', DONWLOAD_ORIGINAL_FILE: 'DOWNLOAD_ORIGINAL_FILE', }; export const NOTIFICATION_KEYS = { + OTP_CODE_RESENT_SUCCESSFULLY: 'OTP_CODE_RESENT_SUCCESSFULLY', ERROR_ADDING_PAYMENT_METHOD: 'ERROR_ADDING_PAYMENT_METHOD', NO_PAYMENT_METHOD_FOUND: 'NO_PAYMENT_METHOD_FOUND', @@ -154,7 +159,9 @@ export const ROUTES = { HOME: '/', SIGNUP: '/auth/signup', SIGNIN: '/auth/signin', - SIGNIN_ONBOARDING: '/auth/onboard', + SIGNUP_ONBOARDING: '/auth/onboard', + OTP_VERIFY: '/auth/verify', + RESET_PASSWORD: '/auth/reset', REQUEST_FORGOT_PASSWORD: '/auth/reset/request', IMPORTS: '/imports', SETTINGS: '/settings', @@ -223,6 +230,11 @@ export const TEXTS = { "Build the best CSV Excel Import Experience for SaaS in 10 Minutes. Onboard customers' data with a hassle-free data importer in your app.", }; +export const IMPORT_MODES = [ + { value: 'manual', label: 'Manual' }, + { value: 'automatic', label: 'Automatic' }, +]; + export const DOCUMENTATION_REFERENCE_LINKS = { defaultValue: 'https://docs.impler.io/platform/default-value', primaryValidation: 'https://docs.impler.io/platform/validators', @@ -235,7 +247,32 @@ export const DOCUMENTATION_REFERENCE_LINKS = { customValidation: 'https://docs.impler.io/features/custom-validation', }; -export const IMPORT_MODES = [ - { label: 'Manual', value: TemplateModeEnum.MANUAL }, - { label: 'Automatic', value: TemplateModeEnum.AUTOMATIC }, +export const COMPANY_SIZES = [ + { value: 'Only me', label: 'Only me' }, + { value: '1-5', label: '1-5' }, + { value: '6-10', label: '6-10' }, + { value: '50-99', label: '50-99' }, + { value: '100+', label: '100+' }, +]; + +export const ROLES = [ + { value: 'Engineer', label: 'Engineer' }, + { value: 'Engineering Manager', label: 'Engineering Manager' }, + { value: 'Architect', label: 'Architect' }, + { value: 'Product Manager', label: 'Product Manager' }, + { value: 'Designer', label: 'Designer' }, + { value: 'Founder', label: 'Founder' }, + { value: 'Marketing Manager', label: 'Marketing Manager' }, + { value: 'Student', label: 'Student' }, + { value: 'CXO (CTO/CEO/Other...)', label: 'CXO (CTO/CEO/Other...)' }, +]; + +export const HOW_HEARD_ABOUT_US = [ + { value: 'Apollo', label: 'Apollo' }, + { value: 'Recommendation', label: 'Recommendation' }, + { value: 'Social Media', label: 'Social Media' }, + { value: 'Google Search', label: 'Google Search' }, + { value: 'Bubble.io', label: 'Bubble.io' }, + { value: 'Colleague', label: 'Colleague' }, + { value: 'Linkdin', label: 'Linkdin' }, ]; diff --git a/apps/web/config/theme.config.ts b/apps/web/config/theme.config.ts index ec4dfb7d7..d503d9153 100644 --- a/apps/web/config/theme.config.ts +++ b/apps/web/config/theme.config.ts @@ -11,6 +11,7 @@ export const colors = { greenDark: '#008489', yellow: '#F7B801', grey: '#B9BEBD', + darkGrey: '#454545', BGPrimaryDark: '#111111', BGPrimaryLight: '#F3F3F3', diff --git a/apps/web/hooks/auth/useResetPassword.tsx b/apps/web/hooks/auth/useResetPassword.tsx index 29bd11025..f5ee592af 100644 --- a/apps/web/hooks/auth/useResetPassword.tsx +++ b/apps/web/hooks/auth/useResetPassword.tsx @@ -5,8 +5,9 @@ import { useMutation } from '@tanstack/react-query'; import { API_KEYS, ROUTES } from '@config'; import { commonApi } from '@libs/api'; -import { IErrorObject, ILoginResponse } from '@impler/shared'; +import { IErrorObject, ILoginResponse, SCREENS } from '@impler/shared'; import { track } from '@libs/amplitude'; +import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; interface IResetPasswordFormData { password: string; @@ -38,9 +39,7 @@ export function useResetPassword() { id: profileData._id, }, }); - if (data.showAddProject) { - push(ROUTES.SIGNIN_ONBOARDING); - } else push(ROUTES.HOME); + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); }, } ); diff --git a/apps/web/hooks/auth/useSignin.tsx b/apps/web/hooks/auth/useSignin.tsx index 71cab2d9f..07040e75d 100644 --- a/apps/web/hooks/auth/useSignin.tsx +++ b/apps/web/hooks/auth/useSignin.tsx @@ -4,15 +4,19 @@ import { useRouter } from 'next/router'; import { useForm } from 'react-hook-form'; import { useMutation } from '@tanstack/react-query'; +import { API_KEYS } from '@config'; import { commonApi } from '@libs/api'; import { track } from '@libs/amplitude'; -import { API_KEYS, ROUTES } from '@config'; -import { IErrorObject, ILoginResponse } from '@impler/shared'; +import { useAppState } from 'store/app.context'; +import { IErrorObject, ILoginResponse, SCREENS } from '@impler/shared'; +import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; export function useSignin() { const { push } = useRouter(); + const { setProfileInfo } = useAppState(); const { register, handleSubmit } = useForm(); const [errorMessage, setErrorMessage] = useState(undefined); + const { mutate: login, isLoading: isLoginLoading } = useMutation< ILoginResponse, IErrorObject, @@ -29,9 +33,8 @@ export function useSignin() { id: profileData._id, }, }); - if (data.showAddProject) { - push(ROUTES.SIGNIN_ONBOARDING); - } else push(ROUTES.HOME); + setProfileInfo(profileData); + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); }, onError(error) { setErrorMessage(error); diff --git a/apps/web/hooks/auth/useSignup.tsx b/apps/web/hooks/auth/useSignup.tsx index f805582ff..77ea9affc 100644 --- a/apps/web/hooks/auth/useSignup.tsx +++ b/apps/web/hooks/auth/useSignup.tsx @@ -4,10 +4,12 @@ import { useRouter } from 'next/router'; import { useForm } from 'react-hook-form'; import { useMutation } from '@tanstack/react-query'; +import { API_KEYS } from '@config'; import { commonApi } from '@libs/api'; import { track } from '@libs/amplitude'; -import { API_KEYS, ROUTES } from '@config'; -import { IErrorObject, ILoginResponse } from '@impler/shared'; +import { useAppState } from 'store/app.context'; +import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; +import { IErrorObject, ILoginResponse, SCREENS } from '@impler/shared'; interface ISignupFormData { fullName: string; @@ -16,14 +18,16 @@ interface ISignupFormData { } export function useSignup() { + const { setProfileInfo } = useAppState(); const { push } = useRouter(); const { setError, register, handleSubmit, formState: { errors }, - } = useForm(); + } = useForm({}); const [errorMessage, setErrorMessage] = useState(undefined); + const { mutate: signup, isLoading: isSignupLoading } = useMutation< ILoginResponse, IErrorObject, @@ -33,6 +37,7 @@ export function useSignup() { onSuccess: (data) => { if (!data) return; const profileData = jwt(data.token as string); + setProfileInfo(profileData); track({ name: 'SIGNUP', properties: { @@ -42,9 +47,7 @@ export function useSignup() { id: profileData._id, }, }); - if (data.showAddProject) { - push(ROUTES.SIGNIN_ONBOARDING); - } else push(ROUTES.HOME); + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); }, onError(error) { if (error.error === 'EmailAlreadyExists') { diff --git a/apps/web/hooks/auth/useVerify.tsx b/apps/web/hooks/auth/useVerify.tsx new file mode 100644 index 000000000..3d85d487c --- /dev/null +++ b/apps/web/hooks/auth/useVerify.tsx @@ -0,0 +1,141 @@ +import { useRouter } from 'next/router'; +import { useForm } from 'react-hook-form'; +import { useState, useEffect, useRef } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { commonApi } from '@libs/api'; +import { notify } from '@libs/notify'; +import { useApp } from '@hooks/useApp'; +import { API_KEYS, NOTIFICATION_KEYS } from '@config'; +import { handleRouteBasedOnScreenResponse } from '@shared/helpers'; +import { IErrorObject, IScreenResponse, SCREENS } from '@impler/shared'; + +interface IVerifyFormData { + otp: string; +} +interface IUpdateEmailFormData { + fullName: string; + email: string; + password: string; +} + +enum ScreenStatesEnum { + VERIFY = 'verify', + UPDATE_EMAIL = 'updateEmail', +} + +const RESEND_SECONDS = 120; + +export function useVerify() { + const { push } = useRouter(); + const { profile } = useApp(); + const timerRef = useRef(); + const { + reset, + setError, + register, + handleSubmit, + formState: { errors }, + } = useForm(); + const queryClient = useQueryClient(); + const [countdown, setCountdown] = useState(RESEND_SECONDS); + const [isButtonDisabled, setIsButtonDisabled] = useState(true); + const [state, setState] = useState(ScreenStatesEnum.VERIFY); + + const { mutate: verify, isLoading: isVerificationLoading } = useMutation< + IScreenResponse, + IErrorObject, + IVerifyFormData + >((body) => commonApi(API_KEYS.VERIFY_EMAIL as any, { body }), { + onSuccess: (data) => { + handleRouteBasedOnScreenResponse(data.screen as SCREENS, push); + }, + onError: (errorObject: IErrorObject) => { + notify(NOTIFICATION_KEYS.OTP_CODE_RESENT_SUCCESSFULLY, { + color: 'red', + title: 'Verfication code is invalid!', + message: errorObject.message, + }); + }, + }); + + const { mutate: resendOTP } = useMutation([API_KEYS.RESEND_OTP], () => commonApi(API_KEYS.RESEND_OTP as any, {}), { + onSuccess: () => { + notify(NOTIFICATION_KEYS.OTP_CODE_RESENT_SUCCESSFULLY, { + color: 'green', + title: 'Verification code sent!', + message: ( + <> + Verification code sent successully to
{profile?.email} + + ), + }); + + setCountdown(RESEND_SECONDS); + setIsButtonDisabled(true); + timerRef.current = setInterval(onCountDownProgress, 1000); + }, + }); + const { mutate: updateEmail, isLoading: isUpdateEmailLoading } = useMutation< + unknown, + IErrorObject, + IUpdateEmailFormData, + (string | undefined)[] + >([API_KEYS.UPDATE_ME_INFO], (data) => commonApi(API_KEYS.UPDATE_ME_INFO as any, { body: data }), { + onSuccess: (_response, data) => { + reset(); + setState(ScreenStatesEnum.VERIFY); + queryClient.invalidateQueries([API_KEYS.ME]); + notify(NOTIFICATION_KEYS.OTP_CODE_RESENT_SUCCESSFULLY, { + color: 'green', + title: 'Verification code sent!', + message: ( + <> + Verification code sent successully to {data?.email} + + ), + }); + }, + onError(error) { + setError('email', { + type: 'manual', + message: error.message, + }); + }, + }); + + const onCountDownProgress = () => { + setCountdown((prevCountdown) => { + if (prevCountdown === 0) { + setIsButtonDisabled(false); // Enable the button when countdown reaches zero + clearInterval(timerRef.current); + } + + return Math.max(0, prevCountdown - 1); + }); + }; + + useEffect(() => { + timerRef.current = setInterval(onCountDownProgress, 1000); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + return { + state, + verify, + errors, + profile, + register, + setState, + resendOTP, + countdown, + ScreenStatesEnum, + isButtonDisabled, + isUpdateEmailLoading, + isVerificationLoading, + updateEmail: handleSubmit((data) => updateEmail(data)), + }; +} diff --git a/apps/web/hooks/useImports.tsx b/apps/web/hooks/useImports.tsx index 32da596d9..6d98ddc49 100644 --- a/apps/web/hooks/useImports.tsx +++ b/apps/web/hooks/useImports.tsx @@ -28,7 +28,7 @@ export function useImports() { const { mutate: createImport, isLoading: isCreateImportLoading } = useMutation< ITemplate, IErrorObject, - ICreateTemplateData, + IUpdateTemplateData, (string | undefined)[] >( [API_KEYS.TEMPLATES_CREATE, profileInfo?._projectId], diff --git a/apps/web/hooks/useOnboardUserProjectForm.tsx b/apps/web/hooks/useOnboardUserProjectForm.tsx new file mode 100644 index 000000000..4246b4352 --- /dev/null +++ b/apps/web/hooks/useOnboardUserProjectForm.tsx @@ -0,0 +1,42 @@ +import { useRouter } from 'next/router'; +import { useMutation } from '@tanstack/react-query'; + +import { API_KEYS } from '@config'; +import { commonApi } from '@libs/api'; +import { track } from '@libs/amplitude'; +import { useAppState } from 'store/app.context'; +import { IErrorObject, IEnvironmentData } from '@impler/shared'; + +export function useOnboardUserProjectForm() { + const { push } = useRouter(); + const { profileInfo, setProfileInfo } = useAppState(); + + const { mutate: onboardUser, isLoading: isUserOnboardLoading } = useMutation< + { onboard: IOnboardUserData; environment: IEnvironmentData }, + IErrorObject, + IOnboardUserData, + string[] + >( + [API_KEYS.ONBOARD_USER], + (apiData) => commonApi(API_KEYS.ONBOARD_USER as any, { body: { ...apiData, onboarding: true } }), + { + onSuccess: () => { + if (profileInfo) { + setProfileInfo({ + ...profileInfo, + _projectId: profileInfo._projectId, + }); + } + track({ + name: 'PROJECT CREATE', + properties: { + duringOnboard: true, + }, + }); + push('/'); + }, + } + ); + + return { onboardUser, isUserOnboardLoading }; +} diff --git a/apps/web/layouts/AppLayout/AppLayout.tsx b/apps/web/layouts/AppLayout/AppLayout.tsx index 9656fa8f1..0756863a7 100644 --- a/apps/web/layouts/AppLayout/AppLayout.tsx +++ b/apps/web/layouts/AppLayout/AppLayout.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router'; import { PropsWithChildren, useRef } from 'react'; import { Flex, Group, LoadingOverlay, Select, Stack, Title, useMantineColorScheme } from '@mantine/core'; +import { TEXTS } from '@config'; import useStyles from './AppLayout.styles'; import { HomeIcon } from '@assets/icons/Home.icon'; import { LogoutIcon } from '@assets/icons/Logout.icon'; @@ -20,7 +21,6 @@ import { NavItem } from '@ui/nav-item'; import { UserMenu } from '@ui/user-menu'; import { track } from '@libs/amplitude'; import { ColorSchemeToggle } from '@ui/toggle-color-scheme'; -import { TEXTS } from '@config'; const Support = dynamic(() => import('components/common/Support').then((mod) => mod.Support), { ssr: false, diff --git a/apps/web/layouts/OnboardLayout/OnboardLayout.styles.tsx b/apps/web/layouts/OnboardLayout/OnboardLayout.styles.tsx index 6e406308f..3025e068a 100644 --- a/apps/web/layouts/OnboardLayout/OnboardLayout.styles.tsx +++ b/apps/web/layouts/OnboardLayout/OnboardLayout.styles.tsx @@ -20,7 +20,6 @@ const getContentContainerStyles = (theme: MantineTheme): Record => display: 'flex', alignItems: 'center', flexDirection: 'column', - textAlign: 'center', padding: theme.spacing.sm, width: '100%', [`@media (min-width: ${theme.breakpoints.sm}px)`]: { @@ -28,8 +27,7 @@ const getContentContainerStyles = (theme: MantineTheme): Record => }, [`@media (min-width: ${theme.breakpoints.md}px)`]: { alignItems: 'flex-start', - textAlign: 'left', - width: '60%', + width: '65%', }, }); const getSlideImageStyles = (): CSSObject => ({ diff --git a/apps/web/layouts/OnboardLayout/OnboardLayout.tsx b/apps/web/layouts/OnboardLayout/OnboardLayout.tsx index d178bd9d2..bce0a75bd 100644 --- a/apps/web/layouts/OnboardLayout/OnboardLayout.tsx +++ b/apps/web/layouts/OnboardLayout/OnboardLayout.tsx @@ -3,10 +3,15 @@ import Image from 'next/image'; import dynamic from 'next/dynamic'; import { PropsWithChildren } from 'react'; import { Carousel } from '@mantine/carousel'; +import { useQuery } from '@tanstack/react-query'; import { Grid, Title, Text, Stack } from '@mantine/core'; -import { TEXTS } from '@config'; +import { commonApi } from '@libs/api'; +import { API_KEYS, TEXTS } from '@config'; +import { IErrorObject } from '@impler/shared'; + import useStyles from './OnboardLayout.styles'; +import { useAppState } from 'store/app.context'; import WidgetSlideImage from '@assets/images/auth-carousel/widget.png'; import PowerfullSlideImage from '@assets/images/auth-carousel/powerfull.png'; import UncertainitySlideImage from '@assets/images/auth-carousel/uncertainity.png'; @@ -39,6 +44,19 @@ const slides: { export function OnboardLayout({ children }: PropsWithChildren) { const { classes } = useStyles(); + const { setProfileInfo } = useAppState(); + useQuery( + [API_KEYS.ME], + () => commonApi(API_KEYS.ME as any, {}), + { + onSuccess(profileData) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.usetifulTags = { userId: profileData?._id }; + setProfileInfo(profileData); + }, + } + ); return ( <> diff --git a/apps/web/libs/api.ts b/apps/web/libs/api.ts index 87dec65b3..e764103a8 100644 --- a/apps/web/libs/api.ts +++ b/apps/web/libs/api.ts @@ -46,6 +46,10 @@ const routes: Record = { url: () => `/v1/user/subscription`, method: 'DELETE', }, + [API_KEYS.ONBOARD_USER]: { + url: () => '/v1/auth/onboard', + method: 'POST', + }, [API_KEYS.PROJECTS_LIST]: { url: () => '/v1/project', @@ -75,6 +79,11 @@ const routes: Record = { url: () => '/v1/auth/register', method: 'POST', }, + + [API_KEYS.VERIFY_EMAIL]: { + url: () => '/v1/auth/verify', + method: 'POST', + }, [API_KEYS.REQUEST_FORGOT_PASSWORD]: { url: () => '/v1/auth/forgot-password/request', method: 'POST', @@ -83,10 +92,22 @@ const routes: Record = { url: () => `/v1/auth/forgot-password/reset`, method: 'POST', }, + [API_KEYS.RESEND_OTP]: { + url: () => `/v1/auth/verify/resend`, + method: 'POST', + }, + [API_KEYS.RESET_PASSWORD]: { + url: () => `/v1/auth/forgot-password/reset`, + method: 'POST', + }, [API_KEYS.ME]: { url: () => `/v1/auth/me`, method: 'GET', }, + [API_KEYS.UPDATE_ME_INFO]: { + url: () => `/v1/auth/me`, + method: 'PUT', + }, [API_KEYS.IMPORT_COUNT]: { url: () => `/v1/user/import-count`, method: 'GET', diff --git a/apps/web/libs/notify.ts b/apps/web/libs/notify.ts index 4e80ba25a..5cfa85a03 100644 --- a/apps/web/libs/notify.ts +++ b/apps/web/libs/notify.ts @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { NOTIFICATION_KEYS } from '@config'; import { notifications } from '@mantine/notifications'; @@ -69,7 +70,7 @@ const Messages: Record = { interface NotifyProps { title?: string; - message: string; + message: string | ReactNode; withCloseButton?: boolean; autoClose?: number; color?: string; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 24e1d5e41..d38e8991b 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,14 +1,36 @@ +import jwtDecode from 'jwt-decode'; import { CONSTANTS, ROUTES } from '@config'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -// This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { - if (!request.cookies.get(CONSTANTS.AUTH_COOKIE_NAME)) - return NextResponse.redirect(new URL(ROUTES.SIGNIN, request.url)); + const path = request.nextUrl.pathname; + const cookie = request.cookies.get(CONSTANTS.AUTH_COOKIE_NAME); + if (!cookie) { + if ( + ![ROUTES.SIGNIN, ROUTES.SIGNUP, ROUTES.REQUEST_FORGOT_PASSWORD].includes(path) && + !path.startsWith(ROUTES.RESET_PASSWORD) + ) { + return NextResponse.redirect(new URL(ROUTES.SIGNIN, request.url)); + } else return; + } + const token = cookie?.value.split(' ')[1]; + const profileData = jwtDecode(token as unknown as string); + + if (!profileData.isEmailVerified) + if (path !== ROUTES.OTP_VERIFY) return NextResponse.redirect(new URL(ROUTES.OTP_VERIFY, request.url)); + else return; + else if (!profileData.accessToken) + if (path !== ROUTES.SIGNUP_ONBOARDING) return NextResponse.redirect(new URL(ROUTES.SIGNUP_ONBOARDING, request.url)); + else return; + else if ([ROUTES.SIGNIN, ROUTES.OTP_VERIFY, ROUTES.SIGNUP_ONBOARDING, ROUTES.SIGNUP].includes(path)) + return NextResponse.redirect(new URL(ROUTES.HOME, request.url)); + else if ((path == ROUTES.SIGNUP, [ROUTES.SIGNUP] && !cookie)) + return NextResponse.redirect(new URL(ROUTES.SIGNUP, request.url)); + // else if (path !== ROUTES.HOME) } -// See "Matching Paths" below to learn more +// Configuration for matching paths export const config = { - matcher: ['/((?!auth*|api|_next/static|_next/image|favicon-light.ico|favicon-dark.ico|images).*)'], + matcher: ['/((?!api|_next/static|_next/image|favicon-light.ico|favicon-dark.ico|images).*)'], }; diff --git a/apps/web/package.json b/apps/web/package.json index 4cd2c2491..2ae3bca79 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@impler/web", - "version": "0.23.1", + "version": "0.24.0", "author": "implerhq", "license": "MIT", "private": true, diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 0cb27f502..281551c8b 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -33,6 +33,10 @@ const client = new QueryClient({ refetchOnWindowFocus: false, retry: false, onError: async (err: any) => { + const path = window.location.pathname; + const isApplicationPath = + ![ROUTES.SIGNIN, ROUTES.SIGNUP, ROUTES.REQUEST_FORGOT_PASSWORD].includes(path) && + !path.startsWith(ROUTES.RESET_PASSWORD); if (err && err.message === 'Failed to fetch') { track({ name: 'ERROR', @@ -41,14 +45,14 @@ const client = new QueryClient({ }, }); notify(NOTIFICATION_KEYS.ERROR_OCCURED); - window.location.href = ROUTES.SIGNIN; + if (isApplicationPath) window.location.href = ROUTES.SIGNIN; } else if (err && err.statusCode === 401) { await commonApi(API_KEYS.LOGOUT as any, {}); track({ name: 'LOGOUT', properties: {}, }); - window.location.href = ROUTES.SIGNIN; + if (isApplicationPath) window.location.href = ROUTES.SIGNIN; } }, }, diff --git a/apps/web/pages/auth/onboard.tsx b/apps/web/pages/auth/onboard.tsx index fe13dfbb0..ba2942ab7 100644 --- a/apps/web/pages/auth/onboard.tsx +++ b/apps/web/pages/auth/onboard.tsx @@ -1,8 +1,8 @@ import { OnboardLayout } from '@layouts/OnboardLayout'; -import CreateProjectForm from '@components/signin/CreateProjectForm'; +import { OnboardUserForm } from '@components/signin/OnboardUserForm'; export default function Onboard() { - return ; + return ; } Onboard.Layout = OnboardLayout; diff --git a/apps/web/pages/auth/signin.tsx b/apps/web/pages/auth/signin.tsx index 414866471..823704fc0 100644 --- a/apps/web/pages/auth/signin.tsx +++ b/apps/web/pages/auth/signin.tsx @@ -28,7 +28,7 @@ export default function SigninPage() { }); if (query.showAddProject) { (window as any).dataLayer?.push({ event: 'github_signup' }); - push(ROUTES.SIGNIN_ONBOARDING); + push(ROUTES.SIGNUP_ONBOARDING); } else push(ROUTES.HOME); } }, [query, push]); diff --git a/apps/web/pages/auth/signup.tsx b/apps/web/pages/auth/signup.tsx index 90070a3fd..9a4b74dc7 100644 --- a/apps/web/pages/auth/signup.tsx +++ b/apps/web/pages/auth/signup.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import Image from 'next/image'; -import { Title, Text, Stack, Flex, TextInput as Input } from '@mantine/core'; +import { Title, Text, Stack, Flex, TextInput as Input, FocusTrap } from '@mantine/core'; import { Button } from '@ui/button'; import { PasswordInput } from '@ui/password-input'; @@ -29,30 +29,32 @@ export default function SignupPage({}) { Signup yourself -
- - - - - - - Already have an account? Sign In - - -
+ +
+ + + + + + + Already have an account? Sign In + + +
+
); } diff --git a/apps/web/pages/auth/verify.tsx b/apps/web/pages/auth/verify.tsx new file mode 100644 index 000000000..f64852d58 --- /dev/null +++ b/apps/web/pages/auth/verify.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { + Container, + Text, + Group, + Divider, + PinInput, + FocusTrap, + UnstyledButton, + Flex, + Stack, + LoadingOverlay, + Title, + TextInput as Input, +} from '@mantine/core'; + +import { colors } from '@config'; +import { RedoIcon } from '@assets/icons/Redo.icon'; +import { EditIcon } from '@assets/icons/Edit.icon'; +import { useVerify } from '@hooks/auth/useVerify'; +import { OnboardLayout } from '@layouts/OnboardLayout'; +import { Button } from '@ui/button'; + +export default function OtpVerifyPage() { + const { + resendOTP, + isButtonDisabled, + countdown, + verify, + register, + profile, + state, + errors, + setState, + updateEmail, + ScreenStatesEnum, + isUpdateEmailLoading, + isVerificationLoading, + } = useVerify(); + const commonStyles = { + color: isButtonDisabled ? colors.white : colors.blue, + cursor: isButtonDisabled ? 'not-allowed' : 'pointer', + opacity: isButtonDisabled ? 0.6 : 1, + }; + + return ( + <> + + + + We sent a verification code to your email + + + + To continue, please enter the 6-digit verification code sent to your email{' '} + setState(ScreenStatesEnum.UPDATE_EMAIL)} disabled={isUpdateEmailLoading}> + + {profile?.email} + + + + + + + + {state === ScreenStatesEnum.VERIFY ? ( + <> + + + verify({ otp })} + /> + + + + + resendOTP()} disabled={isButtonDisabled}> + + + Request new code + + + + {Math.floor(countdown / 60)}:{String(countdown % 60).padStart(2, '0')} + + + + ) : ( + +
+ + + + +
+
+ )} +
+
+ + ); +} + +OtpVerifyPage.Layout = OnboardLayout; diff --git a/apps/web/pages/imports/[id].tsx b/apps/web/pages/imports/[id].tsx index 9068503eb..8b9dc03f4 100644 --- a/apps/web/pages/imports/[id].tsx +++ b/apps/web/pages/imports/[id].tsx @@ -6,6 +6,7 @@ import { ActionIcon, Flex, Group, LoadingOverlay, Title, useMantineTheme, Select import { track } from '@libs/amplitude'; import { useImpler } from '@impler/react'; +import { TemplateModeEnum } from '@impler/shared'; import { IMPORT_MODES, ROUTES, colors } from '@config'; import { useImportDetails } from '@hooks/useImportDetails'; @@ -24,7 +25,6 @@ import { FourIcon } from '@assets/icons/Four.icon'; import { ThreeIcon } from '@assets/icons/Three.icon'; import { DeleteIcon } from '@assets/icons/Delete.icon'; import { LeftArrowIcon } from '@assets/icons/LeftArrow.icon'; -import { TemplateModeEnum } from '@impler/shared'; const Editor = dynamic(() => import('@components/imports/editor').then((mod) => mod.OutputEditor), { ssr: false, diff --git a/apps/web/shared/helpers.ts b/apps/web/shared/helpers.ts new file mode 100644 index 000000000..304c7e3ed --- /dev/null +++ b/apps/web/shared/helpers.ts @@ -0,0 +1,17 @@ +import { SCREENS } from '@impler/shared'; +import { ROUTES } from '@config'; + +export function handleRouteBasedOnScreenResponse(screen: SCREENS, push: (url: string) => void) { + switch (screen) { + case SCREENS.VERIFY: + push(ROUTES.OTP_VERIFY); + break; + case SCREENS.ONBOARD: + push(ROUTES.SIGNUP_ONBOARDING); + break; + case SCREENS.HOME: + default: + push(ROUTES.HOME); + break; + } +} diff --git a/apps/web/types/global.d.ts b/apps/web/types/global.d.ts index 56499cfcf..508830c87 100644 --- a/apps/web/types/global.d.ts +++ b/apps/web/types/global.d.ts @@ -25,8 +25,12 @@ interface IProfileData { lastName: string; email: string; profilePicture: string; + companySize: string; + role: string; + source: string; _projectId: string; accessToken: string; + isEmailVerified: boolean; } interface ChargeItem { @@ -72,7 +76,15 @@ interface ICreateProjectData { name: string; } -interface ICreateTemplateData { +interface IOnboardUserData { + projectName: string; + companySize: string; + role: string; + source: string; + onboarding: boolean; +} + +interface ICstringemplateData { name: string; } interface IDuplicateTemplateData { diff --git a/apps/widget/craco.config.js b/apps/widget/craco.config.js index b38e9b7e3..1cac5b095 100644 --- a/apps/widget/craco.config.js +++ b/apps/widget/craco.config.js @@ -12,11 +12,13 @@ module.exports = { '@amplitude': path.resolve(__dirname, './src/util/amplitude/index.ts'), }, configure: (config) => { + // Optionally, handle source maps if necessary const fileLoaderRule = getFileLoaderRule(config.module.rules); if (!fileLoaderRule) { throw new Error('File loader not found'); } fileLoaderRule.exclude.push(/\.cjs$/); + return config; }, }, @@ -33,4 +35,4 @@ function getFileLoaderRule(rules) { return rule; } } -} \ No newline at end of file +} diff --git a/apps/widget/package.json b/apps/widget/package.json index 80cd5be14..5b9700075 100644 --- a/apps/widget/package.json +++ b/apps/widget/package.json @@ -1,6 +1,6 @@ { "name": "@impler/widget", - "version": "0.23.1", + "version": "0.24.0", "author": "implerhq", "license": "MIT", "private": true, @@ -40,8 +40,8 @@ "@craco/craco": "^6.4.5", "@emotion/react": "^11.10.5", "@handsontable/react": "^14.1.0", - "@impler/client": "^0.23.1", - "@impler/shared": "^0.23.1", + "@impler/client": "^0.24.0", + "@impler/shared": "^0.24.0", "@mantine/core": "6.0.21", "@mantine/dropzone": "6.0.21", "@mantine/hooks": "6.0.21", @@ -57,9 +57,7 @@ "handsontable": "^14.1.0", "http-server": "^14.1.1", "jszip": "^3.10.1", - "moment": "^2.29.4", "react": "18.2.0", - "react-datepicker": "^4.21.0", "react-dom": "18.2.0", "react-hook-form": "^7.39.1", "react-router-dom": "^6.4.2", @@ -67,14 +65,15 @@ "rimraf": "^3.0.2", "tippy.js": "^6.3.7", "web-vitals": "^3.0.4", - "webfontloader": "^1.6.28", - "webpack-dev-server": "^4.11.1" + "webfontloader": "^1.6.28" }, "devDependencies": { + "@babel/preset-typescript": "^7.24.7", "@types/file-saver": "^2.0.5", "@types/react": "^18.2.0", "@types/react-datepicker": "^4.19.1", "@types/react-dom": "^18.2.0", + "ts-loader": "^9.4.1", "typescript": "^4.8.3" } } diff --git a/apps/widget/src/components/Common/Container/Container.tsx b/apps/widget/src/components/Common/Container/Container.tsx index 64637be8d..7ac7cf8c4 100644 --- a/apps/widget/src/components/Common/Container/Container.tsx +++ b/apps/widget/src/components/Common/Container/Container.tsx @@ -7,17 +7,18 @@ import { useEffect, useState, PropsWithChildren } from 'react'; import { Provider } from '../Provider'; import { ApiService } from '@impler/client'; import { MessageHandlerDataType } from '@types'; -import { generateShades, ParentWindow } from '@util'; -import { IShowPayload, WidgetEventTypesEnum } from '@impler/shared'; +import { generateShades, ParentWindow, deepMerge } from '@util'; import { API_URL, colors, mantineConfig, variables } from '@config'; +import { IWidgetShowPayload, WidgetEventTypesEnum, WIDGET_TEXTS, isObject } from '@impler/shared'; let api: ApiService; export function Container({ children }: PropsWithChildren<{}>) { if (!api) api = new ApiService(API_URL); - const [secondaryPayload, setSecondaryPayload] = useState({ + const [secondaryPayload, setSecondaryPayload] = useState({ uuid: '', host: '', + texts: WIDGET_TEXTS, projectId: '', accessToken: '', primaryColor: colors.primary, @@ -46,7 +47,37 @@ export function Container({ children }: PropsWithChildren<{}>) { api.setAuthorizationToken(data.value.accessToken); } setShowWidget(true); - setSecondaryPayload({ ...data.value, primaryColor: data.value.primaryColor || colors.primary }); + setSecondaryPayload({ + accessToken: data.value.accessToken, + host: data.value.host, + projectId: data.value.projectId, + uuid: data.value.uuid, + extra: isObject(data.value.extra) ? JSON.stringify(data.value.extra) : data.value.extra, + templateId: data.value.templateId, + authHeaderValue: data.value.authHeaderValue, + primaryColor: data.value.primaryColor || colors.primary, + colorScheme: data.value.colorScheme, + title: data.value.title, + texts: deepMerge(WIDGET_TEXTS, data.value.texts), + schema: + typeof data.value.schema === 'string' + ? data.value.schema + : Array.isArray(data.value.schema) + ? JSON.stringify(data.value.schema) + : undefined, + data: + typeof data.value.data === 'string' + ? data.value.data + : Array.isArray(data.value.data) + ? JSON.stringify(data.value.data) + : undefined, + output: + typeof data.value.output === 'string' + ? data.value.output + : isObject(data.value.output) + ? JSON.stringify(data.value.output) + : undefined, + }); } else if (data && data.type === WidgetEventTypesEnum.CLOSE_WIDGET) { setShowWidget(false); } @@ -131,6 +162,7 @@ export function Container({ children }: PropsWithChildren<{}>) { output={secondaryPayload?.output} schema={secondaryPayload?.schema} title={secondaryPayload?.title} + texts={secondaryPayload.texts as typeof WIDGET_TEXTS} // api api={api} // impler-context diff --git a/apps/widget/src/components/Common/Footer/AutoImportFooter.tsx b/apps/widget/src/components/Common/Footer/AutoImportFooter.tsx index 1d5ad01bb..161d50e76 100644 --- a/apps/widget/src/components/Common/Footer/AutoImportFooter.tsx +++ b/apps/widget/src/components/Common/Footer/AutoImportFooter.tsx @@ -1,8 +1,8 @@ import { Group, Text } from '@mantine/core'; -import { TEXTS } from '@config'; import useStyles from './Styles'; import { variables } from '@config'; +import { WIDGET_TEXTS } from '@impler/shared'; import { PhasesEnum } from '@types'; import { Button } from '@ui/Button'; import { useAppState } from '@store/app.context'; @@ -15,36 +15,43 @@ interface IFooterProps { secondaryButtonLoading?: boolean; onPrevClick: () => void; onNextClick: () => void; + texts: typeof WIDGET_TEXTS; } -export function AutoImportFooter({ active, onNextClick, primaryButtonLoading, primaryButtonDisabled }: IFooterProps) { +export function AutoImportFooter({ + active, + onNextClick, + primaryButtonLoading, + primaryButtonDisabled, + texts, +}: IFooterProps) { const { importConfig } = useAppState(); const { classes } = useStyles(); const FooterActions = { [PhasesEnum.CONFIGURE]: ( ), [PhasesEnum.MAPCOLUMNS]: ( <> ), [PhasesEnum.SCHEDULE]: ( <> ), [PhasesEnum.CONFIRM]: ( <> ), diff --git a/apps/widget/src/components/Common/Footer/Footer.tsx b/apps/widget/src/components/Common/Footer/Footer.tsx index 0da580c1f..2f23dac57 100644 --- a/apps/widget/src/components/Common/Footer/Footer.tsx +++ b/apps/widget/src/components/Common/Footer/Footer.tsx @@ -1,6 +1,6 @@ import { Group, Text } from '@mantine/core'; import { Button } from '@ui/Button'; -import { TEXTS, variables } from '@config'; +import { variables } from '@config'; import { PhasesEnum } from '@types'; import useStyles from './Styles'; import { useAppState } from '@store/app.context'; @@ -24,20 +24,20 @@ export function Footer({ primaryButtonDisabled, secondaryButtonDisabled, }: IFooterProps) { - const { importConfig } = useAppState(); + const { importConfig, texts } = useAppState(); const { classes } = useStyles(); const FooterActions = { [PhasesEnum.IMAGE_UPLOAD]: ( <> ), [PhasesEnum.UPLOAD]: ( ), [PhasesEnum.MAPPING]: ( @@ -48,10 +48,10 @@ export function Footer({ onClick={onPrevClick} variant="outline" > - {TEXTS.PHASE2.UPLOAD_AGAIN} + {texts.COMMON.UPLOAD_AGAIN} ), @@ -63,10 +63,10 @@ export function Footer({ onClick={onPrevClick} variant="outline" > - {TEXTS.PHASE2.UPLOAD_AGAIN} + {texts.COMMON.UPLOAD_AGAIN} ), @@ -78,10 +78,10 @@ export function Footer({ onClick={onPrevClick} variant="outline" > - {TEXTS.PHASE4.CLOSE} + {texts.COMMON.CLOSE_WIDGET} ), diff --git a/apps/widget/src/components/Common/Heading/Heading.tsx b/apps/widget/src/components/Common/Heading/Heading.tsx index a36085ee1..5bc490c99 100644 --- a/apps/widget/src/components/Common/Heading/Heading.tsx +++ b/apps/widget/src/components/Common/Heading/Heading.tsx @@ -1,48 +1,48 @@ import { Group, MediaQuery, Title, useMantineTheme } from '@mantine/core'; import { PhasesEnum } from '@types'; import { Stepper } from '@ui/Stepper'; -import { TEXTS, variables } from '@config'; -import { TemplateModeEnum } from '@impler/shared'; +import { variables } from '@config'; +import { TemplateModeEnum, WIDGET_TEXTS } from '@impler/shared'; interface IHeadingProps { title?: string; + texts: typeof WIDGET_TEXTS; active: PhasesEnum; mode?: TemplateModeEnum; hasImageUpload?: boolean; } -const manualImportSteps = [ - { - label: TEXTS.STEPS.UPLOAD, - }, - { - label: TEXTS.STEPS.MAPPING, - }, - { - label: TEXTS.STEPS.REVIEW, - }, - { - label: TEXTS.STEPS.COMPLETE, - }, -]; - -const autoImportSteps = [ - { - label: TEXTS.AUTOIMPORTSTEPS.CONFIGURE, - }, - { - label: TEXTS.AUTOIMPORTSTEPS.MAPCOLUMNS, - }, - { - label: TEXTS.AUTOIMPORTSTEPS.SCHEDULE, - }, - { - label: TEXTS.AUTOIMPORTSTEPS.CONFIRM, - }, -]; - -export function Heading({ active, title, mode, hasImageUpload }: IHeadingProps) { +export function Heading({ active, title, mode, hasImageUpload, texts }: IHeadingProps) { const theme = useMantineTheme(); + const manualImportSteps = [ + { + label: texts.STEPPER_TITLES.UPLOAD_FILE, + }, + { + label: texts.STEPPER_TITLES.MAP_COLUMNS, + }, + { + label: texts.STEPPER_TITLES.REVIEW_DATA, + }, + { + label: texts.STEPPER_TITLES.COMPLETE_IMPORT, + }, + ]; + + const autoImportSteps = [ + { + label: texts.STEPPER_TITLES.CONFIGURE_JOB, + }, + { + label: texts.STEPPER_TITLES.MAP_COLUMNS, + }, + { + label: texts.STEPPER_TITLES.SCHEDULE_JOB, + }, + { + label: texts.STEPPER_TITLES.CONFIRM_JOB, + }, + ]; return ( @@ -57,7 +57,7 @@ export function Heading({ active, title, mode, hasImageUpload }: IHeadingProps) : hasImageUpload ? [ { - label: TEXTS.STEPS.IMAGE_TEMPLATE, + label: texts.STEPPER_TITLES.GENERATE_TEMPLATE, }, ...manualImportSteps, ] diff --git a/apps/widget/src/components/Common/Layout/Layout.tsx b/apps/widget/src/components/Common/Layout/Layout.tsx index 99be57e7f..9676e8188 100644 --- a/apps/widget/src/components/Common/Layout/Layout.tsx +++ b/apps/widget/src/components/Common/Layout/Layout.tsx @@ -1,23 +1,24 @@ import { PropsWithChildren } from 'react'; import useStyles from './Styles'; import { PhasesEnum } from '@types'; -import { TemplateModeEnum } from '@impler/shared'; +import { TemplateModeEnum, WIDGET_TEXTS } from '@impler/shared'; import { Heading } from 'components/Common/Heading'; interface ILayoutProps { active: PhasesEnum; title?: string; + texts: typeof WIDGET_TEXTS; mode?: TemplateModeEnum; hasImageUpload?: boolean; } export function Layout(props: PropsWithChildren) { const { classes } = useStyles(); - const { children, active, title, hasImageUpload, mode } = props; + const { children, active, title, hasImageUpload, mode, texts } = props; return (
- +
{children}
); diff --git a/apps/widget/src/components/Common/Provider/Provider.tsx b/apps/widget/src/components/Common/Provider/Provider.tsx index 27db94576..8e3be2cc0 100644 --- a/apps/widget/src/components/Common/Provider/Provider.tsx +++ b/apps/widget/src/components/Common/Provider/Provider.tsx @@ -4,14 +4,16 @@ import ImplerContextProvider from '@store/impler.context'; import APIContextProvider from '@store/api.context'; import AppContextProvider from '@store/app.context'; import { JobsInfoProvider } from '@store/jobinfo.context'; +import { WIDGET_TEXTS } from '@impler/shared'; interface IProviderProps { // app-context title?: string; + texts: typeof WIDGET_TEXTS; primaryColor: string; output?: string; schema?: string; - data?: Record[]; + data?: string; host: string; showWidget: boolean; setShowWidget: (status: boolean) => void; @@ -29,6 +31,7 @@ export function Provider(props: PropsWithChildren) { api, data, title, + texts, output, projectId, templateId, @@ -55,6 +58,7 @@ export function Provider(props: PropsWithChildren) { host={host} data={data} title={title} + texts={texts} output={output} schema={schema} showWidget={showWidget} diff --git a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase1/AutoImportPhase1.tsx b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase1/AutoImportPhase1.tsx index 9c157de3b..4b9a17cef 100644 --- a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase1/AutoImportPhase1.tsx +++ b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase1/AutoImportPhase1.tsx @@ -1,15 +1,17 @@ import { Stack, TextInput } from '@mantine/core'; import { PhasesEnum } from '@types'; +import { WIDGET_TEXTS } from '@impler/shared'; import { validateRssUrl } from '@util'; import { AutoImportFooter } from 'components/Common/Footer/AutoImportFooter'; import { useAutoImportPhase1 } from '../hooks/AutoImportPhase1/useAutoImportPhase1'; interface IAutoImportPhase1Props { onNextClick: () => void; + texts: typeof WIDGET_TEXTS; } -export function AutoImportPhase1({ onNextClick }: IAutoImportPhase1Props) { +export function AutoImportPhase1({ onNextClick, texts }: IAutoImportPhase1Props) { const { isLoading, register, errors, onSubmit } = useAutoImportPhase1({ goNext: onNextClick, }); @@ -39,6 +41,7 @@ export function AutoImportPhase1({ onNextClick }: IAutoImportPhase1Props) { primaryButtonLoading={isLoading} onPrevClick={() => {}} active={PhasesEnum.CONFIGURE} + texts={texts} /> ); diff --git a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/AutoImportPhase2.tsx b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/AutoImportPhase2.tsx index 7f712e96b..4d2472dae 100644 --- a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/AutoImportPhase2.tsx +++ b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/AutoImportPhase2.tsx @@ -8,13 +8,15 @@ import { MappingItem } from '@ui/MappingItem'; import { MappingHeading } from './MappingHeading'; import { AutoImportFooter } from 'components/Common/Footer/AutoImportFooter'; import { useAutoImportPhase2 } from '../hooks/AutoImportPhase2/useAutoImportPhase2'; +import { WIDGET_TEXTS } from '@impler/shared'; interface IAutoImportPhase2Props { onNextClick: () => void; + texts: typeof WIDGET_TEXTS; } const defaulWrappertHeight = 200; -export function AutoImportPhase2({ onNextClick }: IAutoImportPhase2Props) { +export function AutoImportPhase2({ onNextClick, texts }: IAutoImportPhase2Props) { const { classes } = useStyles(); const [wrapperHeight, setWrapperHeight] = useState(defaulWrappertHeight); const { control, mappings, onSubmit, onFieldSelect, headings } = useAutoImportPhase2({ @@ -35,7 +37,7 @@ export function AutoImportPhase2({ onNextClick }: IAutoImportPhase2Props) { return ( <>
- +
{}} active={PhasesEnum.MAPCOLUMNS} + texts={texts} /> ); diff --git a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/MappingHeading/MappingHeading.tsx b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/MappingHeading/MappingHeading.tsx index fcabd5f9c..825676761 100644 --- a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/MappingHeading/MappingHeading.tsx +++ b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase2/MappingHeading/MappingHeading.tsx @@ -1,19 +1,23 @@ import React from 'react'; -import { TEXTS } from '@config'; import useStyles from './Styles'; import { Group, Text } from '@mantine/core'; +import { WIDGET_TEXTS } from '@impler/shared'; -export const MappingHeading = React.forwardRef((props, ref) => { +interface IMappingHeadingProps { + texts: typeof WIDGET_TEXTS; +} + +export const MappingHeading = React.forwardRef(({ texts }, ref) => { const { classes } = useStyles(); return ( - {TEXTS.AUTOIMPORTPHASE2.NAME_IN_SCHEMA_TITLE} + {texts.AUTOIMPORT_PHASE2.IN_SCHEMA_TITLE} - {TEXTS.AUTOIMPORTPHASE2.KEY_IN_FEED_TITLE} + {texts.AUTOIMPORT_PHASE2.IN_FEED_TITLE} diff --git a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase3/AutoImportPhase3.tsx b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase3/AutoImportPhase3.tsx index 8f3f76f46..debc16b98 100644 --- a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase3/AutoImportPhase3.tsx +++ b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase3/AutoImportPhase3.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Group, Text, Stack, Flex, Container } from '@mantine/core'; const parseCronExpression = require('util/helpers/cronstrue'); -import { colors, cronExampleBadges, cronExamples, ScheduleFormValues, defaultCronValues, TEXTS } from '@config'; +import { colors, cronExampleBadges, cronExamples, ScheduleFormValues, defaultCronValues } from '@config'; +import { WIDGET_TEXTS } from '@impler/shared'; import { PhasesEnum } from '@types'; import { AutoImportFooter } from 'components/Common/Footer'; import { CronScheduleInputTextBox } from './CronScheduleInputTextBox'; @@ -12,9 +13,10 @@ import { useAutoImportPhase3 } from '../hooks/AutoImportPhase3/useAutoImportPhas interface IAutoImportPhase3Props { onNextClick: () => void; + texts: typeof WIDGET_TEXTS; } -export function AutoImportPhase3({ onNextClick }: IAutoImportPhase3Props) { +export function AutoImportPhase3({ onNextClick, texts }: IAutoImportPhase3Props) { const [tableOpened, setTableOpened] = useState(false); const { control, watch, setValue } = useForm({ defaultValues: defaultCronValues, @@ -47,13 +49,13 @@ export function AutoImportPhase3({ onNextClick }: IAutoImportPhase3Props) { } const description: string = parseCronExpression.toString(cronExpression); if (description.includes('undefined')) { - return { description: TEXTS.INVALID_CRON.MESSAGE, isError: true }; + return { description: texts.AUTOIMPORT_PHASE3.INVALID_CRON_MESSAGE, isError: true }; } return { description, isError: false }; } catch (error) { return { - description: TEXTS.INVALID_CRON.MESSAGE, + description: texts.AUTOIMPORT_PHASE3.INVALID_CRON_MESSAGE, isError: true, }; } @@ -139,7 +141,12 @@ export function AutoImportPhase3({ onNextClick }: IAutoImportPhase3Props) { /> - {}} onNextClick={handleNextClick} /> + {}} + onNextClick={handleNextClick} + /> ); } diff --git a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase4/AutoImportPhase4.tsx b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase4/AutoImportPhase4.tsx index bb823467d..29ef87f10 100644 --- a/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase4/AutoImportPhase4.tsx +++ b/apps/widget/src/components/widget/Phases/AutoImportPhases/AutoImportPhase4/AutoImportPhase4.tsx @@ -1,5 +1,6 @@ import { colors } from '@config'; import { CheckIcon } from '@icons'; +import { WIDGET_TEXTS } from '@impler/shared'; import { Text, Stack, Paper } from '@mantine/core'; import { useJobsInfo } from '@store/jobinfo.context'; const parseCronExpression = require('util/helpers/cronstrue'); @@ -9,9 +10,10 @@ import { AutoImportFooter } from 'components/Common/Footer/AutoImportFooter'; interface IAutoImportPhase4Props { onCloseClick: () => void; + texts: typeof WIDGET_TEXTS; } -export function AutoImportPhase4({ onCloseClick }: IAutoImportPhase4Props) { +export function AutoImportPhase4({ onCloseClick, texts }: IAutoImportPhase4Props) { const { jobsInfo } = useJobsInfo(); return ( @@ -41,6 +43,7 @@ export function AutoImportPhase4({ onCloseClick }: IAutoImportPhase4Props) { primaryButtonLoading={false} onPrevClick={() => {}} active={PhasesEnum.CONFIRM} + texts={texts} /> ); diff --git a/apps/widget/src/components/widget/Phases/Phase0-1/Phase0-1.tsx b/apps/widget/src/components/widget/Phases/Phase0-1/Phase0-1.tsx index 9b480268a..6b31758b7 100644 --- a/apps/widget/src/components/widget/Phases/Phase0-1/Phase0-1.tsx +++ b/apps/widget/src/components/widget/Phases/Phase0-1/Phase0-1.tsx @@ -6,7 +6,8 @@ import { ReactNode, useEffect, useRef, useState } from 'react'; import { Warning } from '@icons'; import { Select } from '@ui/Select'; import { PhasesEnum } from '@types'; -import { colors, TEXTS, variables } from '@config'; +import { WIDGET_TEXTS } from '@impler/shared'; +import { colors, variables } from '@config'; import { FileDropzone } from '@ui/FileDropzone'; import { Footer } from 'components/Common/Footer'; import { usePhase01 } from '@hooks/Phase0-1/usePhase01'; @@ -14,9 +15,10 @@ import { ImageWithIndicator } from '@ui/ImageWithIndicator'; interface Phase01Props { goToUpload: () => void; + texts: typeof WIDGET_TEXTS; } -export function Phase01({ goToUpload }: Phase01Props) { +export function Phase01({ goToUpload, texts }: Phase01Props) { const [showAlert, setShowAlert] = useLocalStorage({ key: variables.SHOW_IMAGE_ALERT_STORAGE_KEY, defaultValue: true, @@ -47,10 +49,10 @@ export function Phase01({ goToUpload }: Phase01Props) { color="blue" withCloseButton onClose={() => setShowAlert(false)} - title={TEXTS['PHASE0-1'].ALERT_TITLE} + title={texts['PHASE0-1'].IMAGE_INFO_TITLE} icon={} > - {TEXTS['PHASE0-1'].ALERT_SUBTITLE} + {texts['PHASE0-1'].IMAGE_INFO_SUBTITLE} )} - + {Object.entries( fields.reduce((acc, field, index) => { diff --git a/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx b/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx index 7744126b4..afe1ffd8c 100644 --- a/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx +++ b/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx @@ -4,7 +4,9 @@ import { Controller } from 'react-hook-form'; import { PhasesEnum } from '@types'; import { Select } from '@ui/Select'; import { Button } from '@ui/Button'; -import { TEXTS, variables } from '@config'; + +import { WIDGET_TEXTS } from '@impler/shared'; +import { variables } from '@config'; import { DownloadIcon, BackIcon } from '@icons'; import { UploadDropzone } from '@ui/UploadDropzone'; import { usePhase1 } from '@hooks/Phase1/usePhase1'; @@ -17,9 +19,10 @@ interface IPhase1Props { onNextClick: () => void; hasImageUpload: boolean; generateImageTemplate: () => void; + texts: typeof WIDGET_TEXTS; } -export function Phase1({ onNextClick: goNext, hasImageUpload, generateImageTemplate }: IPhase1Props) { +export function Phase1({ onNextClick: goNext, hasImageUpload, generateImageTemplate, texts }: IPhase1Props) { const { classes } = useStyles(); const { onSubmit, @@ -34,8 +37,10 @@ export function Phase1({ onNextClick: goNext, hasImageUpload, generateImageTempl showSelectTemplate, isDownloadInProgress, onSelectSheetModalReset, + isExcelSheetNamesLoading, } = usePhase1({ goNext, + texts, }); const onDownload = () => { if (hasImageUpload) generateImageTemplate(); @@ -50,7 +55,7 @@ export function Phase1({ onNextClick: goNext, hasImageUpload, generateImageTempl name={`templateId`} control={control} rules={{ - required: TEXTS.VALIDATION.TEMPLATE_REQUIRED, + required: texts.PHASE1.SELECT_SHEET_REQUIRED_MSG, }} render={({ field, fieldState }) => ( )} /> diff --git a/apps/widget/src/components/widget/Phases/Phase2/MappingHeading/MappingHeading.tsx b/apps/widget/src/components/widget/Phases/Phase2/MappingHeading/MappingHeading.tsx index a25d46953..d8967e37d 100644 --- a/apps/widget/src/components/widget/Phases/Phase2/MappingHeading/MappingHeading.tsx +++ b/apps/widget/src/components/widget/Phases/Phase2/MappingHeading/MappingHeading.tsx @@ -1,19 +1,23 @@ import React from 'react'; -import { TEXTS } from '@config'; +import { WIDGET_TEXTS } from '@impler/shared'; import useStyles from './Styles'; import { Group, Text } from '@mantine/core'; -export const MappingHeading = React.forwardRef((props, ref) => { +interface IMappingHeadingProps { + texts: typeof WIDGET_TEXTS; +} + +export const MappingHeading = React.forwardRef(({ texts }, ref) => { const { classes } = useStyles(); return ( - {TEXTS.PHASE2.NAME_IN_SCHEMA_TITLE} + {texts.PHASE2.IN_SCHEMA_TITLE} - {TEXTS.PHASE2.NAME_IN_SHEET_TITLE} + {texts.PHASE2.IN_SHEET_TITLE} diff --git a/apps/widget/src/components/widget/Phases/Phase2/Phase2.tsx b/apps/widget/src/components/widget/Phases/Phase2/Phase2.tsx index 9cd34d00d..29a615707 100644 --- a/apps/widget/src/components/widget/Phases/Phase2/Phase2.tsx +++ b/apps/widget/src/components/widget/Phases/Phase2/Phase2.tsx @@ -3,6 +3,7 @@ import { Footer } from 'components/Common/Footer'; import useStyles from './Styles'; import { useEffect, useRef, useState } from 'react'; import { usePhase2 } from '@hooks/Phase2/usePhase2'; +import { WIDGET_TEXTS } from '@impler/shared'; import { PhasesEnum } from '@types'; import { Controller } from 'react-hook-form'; import { MappingHeading } from './MappingHeading'; @@ -11,12 +12,13 @@ import { LoadingOverlay } from '@ui/LoadingOverlay'; interface IPhase2Props { onPrevClick: () => void; onNextClick: () => void; + texts: typeof WIDGET_TEXTS; } const defaulWrappertHeight = 200; export function Phase2(props: IPhase2Props) { const { classes } = useStyles(); - const { onPrevClick, onNextClick } = props; + const { onPrevClick, onNextClick, texts } = props; const [wrapperHeight, setWrapperHeight] = useState(defaulWrappertHeight); const { headings, mappings, control, onSubmit, onFieldSelect, isInitialDataLoaded, isMappingFinalizing } = usePhase2({ goNext: onNextClick, @@ -36,7 +38,7 @@ export function Phase2(props: IPhase2Props) {
{/* Heading */} - + {/* Mapping Items */}
[]) => void; onPrevClick: () => void; + texts: typeof WIDGET_TEXTS; } export function Phase3(props: IPhase3Props) { const tableRef = useRef(null); - const { onNextClick, onPrevClick } = props; + const { onNextClick, onPrevClick, texts } = props; const { page, type, @@ -185,6 +185,7 @@ export function Phase3(props: IPhase3Props) { onPrevClick={onPrevClick} /> setShowAllDataValidModal(false)} onConfirm={onReviewConfirmed} @@ -192,7 +193,7 @@ export function Phase3(props: IPhase3Props) { /> setShowDeleteConfirmModal(false)} - title={replaceVariablesInString(TEXTS.DELETE_CONFIRMATION.TITLE, { + title={replaceVariablesInString(texts.DELETE_RECORDS_CONFIRMATION.TITLE, { total: numberFormatter(selectedRowsRef.current.size), })} onConfirm={() => { @@ -203,10 +204,10 @@ export function Phase3(props: IPhase3Props) { selectedRowsCountRef.current.invalid.size, ]); }} - cancelLabel={TEXTS.DELETE_CONFIRMATION.NO} - confirmLabel={TEXTS.DELETE_CONFIRMATION.YES} + cancelLabel={texts.DELETE_RECORDS_CONFIRMATION.CANCEL_DELETE} + confirmLabel={texts.DELETE_RECORDS_CONFIRMATION.CONFIRM_DELETE} opened={!!showDeleteConfirmModal} - subTitle={TEXTS.DELETE_CONFIRMATION.SUBTITLE} + subTitle={texts.DELETE_RECORDS_CONFIRMATION.DETAILS} /> ); diff --git a/apps/widget/src/components/widget/Phases/Phase3/ReviewConfirmModal/ReviewConfirmModal.tsx b/apps/widget/src/components/widget/Phases/Phase3/ReviewConfirmModal/ReviewConfirmModal.tsx index f19764a1d..48e9bf497 100644 --- a/apps/widget/src/components/widget/Phases/Phase3/ReviewConfirmModal/ReviewConfirmModal.tsx +++ b/apps/widget/src/components/widget/Phases/Phase3/ReviewConfirmModal/ReviewConfirmModal.tsx @@ -1,18 +1,19 @@ -import { CheckIcon } from '@icons'; -import { colors, TEXTS } from '@config'; import { Button } from '@ui/Button'; import { Group, Modal as MantineModal, Text, Title } from '@mantine/core'; -import { replaceVariablesInString, numberFormatter } from '@impler/shared'; +import { CheckIcon } from '@icons'; +import { colors } from '@config'; +import { replaceVariablesInString, numberFormatter, WIDGET_TEXTS } from '@impler/shared'; interface IConfirmModalProps { opened: boolean; onClose: () => void; totalRecords: number; onConfirm: () => void; + texts: typeof WIDGET_TEXTS; } export function ReviewConfirmModal(props: IConfirmModalProps) { - const { opened, onClose, onConfirm, totalRecords } = props; + const { opened, onClose, onConfirm, totalRecords, texts } = props; return ( @@ -28,18 +29,18 @@ export function ReviewConfirmModal(props: IConfirmModalProps) { }} /> - All records are found valid! + {texts.PHASE3?.ALL_RECORDS_VALID_TITLE} - {replaceVariablesInString(TEXTS.PHASE3.ALL_VALID_CONFIRMATION, { + {replaceVariablesInString(texts.PHASE3.ALL_RECORDS_VALID_DETAILS, { total: numberFormatter(totalRecords), })} - + diff --git a/apps/widget/src/components/widget/Phases/Phase4/Phase4.tsx b/apps/widget/src/components/widget/Phases/Phase4/Phase4.tsx index 822ce106e..b668ab274 100644 --- a/apps/widget/src/components/widget/Phases/Phase4/Phase4.tsx +++ b/apps/widget/src/components/widget/Phases/Phase4/Phase4.tsx @@ -1,32 +1,34 @@ import { Group, Title, Text } from '@mantine/core'; -import { TEXTS } from '@config'; -import { CheckIcon } from '@icons'; -import { Footer } from 'components/Common/Footer'; + import useStyles from './Styles'; +import { CheckIcon } from '@icons'; import { PhasesEnum } from '@types'; -import { numberFormatter, replaceVariablesInString } from '@impler/shared'; import { useAppState } from '@store/app.context'; +import { Footer } from 'components/Common/Footer'; +import { numberFormatter, replaceVariablesInString } from '@impler/shared'; +import { WIDGET_TEXTS } from '@impler/shared/src/config/texts.config'; interface IPhase4Props { + rowsCount: number; + texts: typeof WIDGET_TEXTS; onCloseClick: () => void; onUploadAgainClick: () => void; - rowsCount: number; } export function Phase4(props: IPhase4Props) { const { classes } = useStyles(); const { primaryColor } = useAppState(); - const { rowsCount, onUploadAgainClick, onCloseClick } = props; + const { rowsCount, onUploadAgainClick, onCloseClick, texts } = props; return ( <> - {replaceVariablesInString(TEXTS.COMPLETE.TITLE, { count: numberFormatter(rowsCount) })} + {replaceVariablesInString(texts.PHASE4.TITLE, { count: numberFormatter(rowsCount) })} - {replaceVariablesInString(TEXTS.COMPLETE.SUB_TITLE, { count: numberFormatter(rowsCount) })} + {replaceVariablesInString(texts.PHASE4.SUB_TITLE, { count: numberFormatter(rowsCount) })} diff --git a/apps/widget/src/components/widget/Widget.tsx b/apps/widget/src/components/widget/Widget.tsx index dbf018582..49a6f5294 100644 --- a/apps/widget/src/components/widget/Widget.tsx +++ b/apps/widget/src/components/widget/Widget.tsx @@ -2,15 +2,15 @@ import { useCallback, useEffect, useState } from 'react'; import { Modal } from '@ui/Modal'; import { ParentWindow } from '@util'; -import { TEXTS, variables } from '@config'; +import { variables } from '@config'; import { useWidget } from '@hooks/useWidget'; import { useAppState } from '@store/app.context'; import { Layout } from 'components/Common/Layout'; import { ConfirmModal } from './modals/ConfirmModal'; import { useTemplates } from '@hooks/useTemplates'; import { PhasesEnum, PromptModalTypesEnum } from '@types'; -import { IImportConfig, IUpload, TemplateModeEnum } from '@impler/shared'; import { logAmplitudeEvent, resetAmplitude } from '@amplitude'; +import { IImportConfig, IUpload, TemplateModeEnum } from '@impler/shared'; import { Phase0 } from './Phases/Phase0'; import { Phase01 } from './Phases/Phase0-1'; @@ -38,6 +38,7 @@ export function Widget() { setShowWidget, setImportConfig, reset: resetAppState, + texts, } = useAppState(); const onUploadResetClick = () => { @@ -95,35 +96,33 @@ export function Widget() { [PhasesEnum.VALIDATE]: , ...(importConfig.mode === TemplateModeEnum.AUTOMATIC ? { - [PhasesEnum.CONFIGURE]: setPhase(PhasesEnum.MAPCOLUMNS)} />, - [PhasesEnum.MAPCOLUMNS]: setPhase(PhasesEnum.SCHEDULE)} />, - [PhasesEnum.SCHEDULE]: setPhase(PhasesEnum.CONFIRM)} />, - [PhasesEnum.CONFIRM]: , + [PhasesEnum.CONFIGURE]: ( + setPhase(PhasesEnum.MAPCOLUMNS)} /> + ), + [PhasesEnum.MAPCOLUMNS]: setPhase(PhasesEnum.SCHEDULE)} />, + [PhasesEnum.SCHEDULE]: setPhase(PhasesEnum.CONFIRM)} texts={texts} />, + [PhasesEnum.CONFIRM]: , } : { - [PhasesEnum.IMAGE_UPLOAD]: setPhase(PhasesEnum.UPLOAD)} />, + [PhasesEnum.IMAGE_UPLOAD]: setPhase(PhasesEnum.UPLOAD)} />, [PhasesEnum.UPLOAD]: ( setPhase(PhasesEnum.MAPPING)} generateImageTemplate={() => setPhase(PhasesEnum.IMAGE_UPLOAD)} /> ), [PhasesEnum.MAPPING]: ( - setPhase(PhasesEnum.REVIEW)} onPrevClick={onUploadResetClick} /> + setPhase(PhasesEnum.REVIEW)} onPrevClick={onUploadResetClick} /> ), - [PhasesEnum.REVIEW]: , + [PhasesEnum.REVIEW]: , [PhasesEnum.COMPLETE]: ( - + ), }), }; - const subTitle = { - [PromptModalTypesEnum.CLOSE]: TEXTS.PROMPT.SUBTITLE_CLOSE, - [PromptModalTypesEnum.UPLOAD_AGAIN]: TEXTS.PROMPT.SUBTITLE_RESET, - }; - useEffect(() => { if (!showWidget) { setPhase(PhasesEnum.VALIDATE); @@ -134,21 +133,21 @@ export function Widget() { {PhaseView[phase]} diff --git a/apps/widget/src/config/index.ts b/apps/widget/src/config/index.ts index 3141b2b5c..74133021f 100644 --- a/apps/widget/src/config/index.ts +++ b/apps/widget/src/config/index.ts @@ -1,5 +1,4 @@ export * from './app.config'; export * from './theme.config'; export * from './colors.config'; -export * from './texts.config'; export * from './variable.config'; diff --git a/apps/widget/src/config/texts.config.ts b/apps/widget/src/config/texts.config.ts deleted file mode 100644 index 46876acb5..000000000 --- a/apps/widget/src/config/texts.config.ts +++ /dev/null @@ -1,119 +0,0 @@ -export const TEXTS = { - TITLES: { - UPLOAD: 'Upload', - MAPPING: 'Map Columns', - REVIEW: 'Review', - COMPLETE: 'Complete', - }, - STEPS: { - IMAGE_TEMPLATE: 'Generate Template', - UPLOAD: 'Upload', - MAPPING: 'Map Columns', - REVIEW: 'Review', - COMPLETE: 'Complete', - }, - AUTOIMPORTSTEPS: { - CONFIGURE: 'Configure', - MAPCOLUMNS: 'Map Columns', - SCHEDULE: 'Schedule', - CONFIRM: 'Confirm', - }, - FILE_DROPZONE: { - TITLE: 'Drop and drop file here or ', - BROWSE: 'Browse from computer', - FILE_SIZE: 'Image size should be less than 5 MB. Supported formats are PNG, JPG and JPEG.', - SUBTITLE: 'Bring any .csv or .xlsx file here to start Import', - FILE_SELECTION: 'File selected successfully', - }, - 'PHASE0-1': { - IMPORT_FILE: 'Import File', - GENERATE_TEMPLATE: 'Generate Template', - ALERT_TITLE: 'Generate template with images', - ALERT_SUBTITLE: - 'Drag and drop images below for image columns and generate a template file containing names of uploaded images.', - }, - PHASE1: { - SELECT_TITLE: 'Template', - SELECT_EXCEL_SHEET: 'Select sheet to Import', - SELECT_EXCEL_SHEET_PLACEHOLDER: 'Select Excel sheet', - SELECT_PLACEHOLDER: 'Select Template', - DOWNLOAD_SAMPLE_TITLE: 'Download sample csv file', - DOWNLOAD_SAMPLE: 'Download sample', - GENERATE_TEMPLATE: 'Generate Template', - SEE_MAPPING: 'See Mapping', - SELECT_FILE: 'Select a file', - }, - PHASE2: { - UPLOAD_AGAIN: 'Upload Again', - SEE_REVIEW: 'Review Data', - NAME_IN_SCHEMA_TITLE: 'Column in schema', - NAME_IN_SHEET_TITLE: 'Column in your sheet', - }, - PHASE3: { - EXPORT_DATA: 'Export Data', - RE_REVIEW_DATA: 'Re-Review Data', - COMPLETE: 'Complete', - ALL_VALID_CONFIRMATION: 'All {total} row(s) found valid! Would you like to complete the Import?', - }, - PHASE4: { - CLOSE: 'Close', - }, - - AUTOIMPORTPHASES: { - BUTTONTEXT: { - MAPCOLUMN: 'Map Column', - SCHEDULE: 'Schedule', - CONFIRM: 'Confirm', - CLOSE: 'Close', - }, - }, - AUTOIMPORTPHASE2: { - NAME_IN_SCHEMA_TITLE: 'Column in schema', - KEY_IN_FEED_TITLE: 'Key in your RSS feed ', - }, - - AUTOIMPORTPHASETITLE: { - CONFIGURE: 'Configure', - }, - INVALID_CRON: { - MESSAGE: 'Expression values are incorrect. Please update values as per valid values below!', - }, - - COMPLETE: { - TITLE: 'Bravo! {count} rows have been uploaded', - SUB_TITLE: '{count} rows have been uploaded successfully, and currently is in process, it will be ready shortly.', - UPLOAD_AGAIN: 'Upload new File', - }, - SELECT_SHEET_MODAL: { - SELECT: 'Select', - }, - DELETE_CONFIRMATION: { - TITLE: `{total} rows will be deleted. Are you sure?`, - SUBTITLE: 'This action cannot be undone.', - YES: 'Yes', - NO: 'Cancel', - }, - PROMPT: { - TITLE: `Are you sure? You will lose your work in progress.`, - SUBTITLE_CLOSE: 'Your import is in progress, clicking Yes will reset it.', - SUBTITLE_RESET: 'Your import is in progress, clicking Yes will reset it.', - YES: 'Yes', - NO: 'No', - }, - VALIDATION: { - REQUIRED_SELECT: 'Please select value from the list', - TEMPLATE_REQUIRED: 'Template is required', - FILE_REQUIRED: 'File is required', - }, - NOTIFICATIONS: { - INCOMPLETE_TEMPLATE: { - title: 'Sorry!', - message: 'This import do not have any columns. Please try again after some time!', - }, - TEMPLATE_NOT_FOUND: { - title: 'Sorry!', - message: - "We couldn't find the template you're importing, Our team is informed about it. Please try again after some time!", - }, - }, -}; diff --git a/apps/widget/src/design-system/FileDropzone/FileDropzone.tsx b/apps/widget/src/design-system/FileDropzone/FileDropzone.tsx index caf23ef57..3fe77e5bd 100644 --- a/apps/widget/src/design-system/FileDropzone/FileDropzone.tsx +++ b/apps/widget/src/design-system/FileDropzone/FileDropzone.tsx @@ -1,8 +1,9 @@ import { Group, Text } from '@mantine/core'; import { Dropzone as MantineDropzone, FileWithPath, MIME_TYPES } from '@mantine/dropzone'; import { ImageIcon } from '../../icons'; +import { WIDGET_TEXTS } from '@impler/shared'; import useStyles from './FileDropdown.styles'; -import { TEXTS, variables } from '../../config'; +import { variables } from '../../config'; interface IDropzoneProps { loading?: boolean; @@ -11,6 +12,7 @@ interface IDropzoneProps { onDrop: (files: FileWithPath[]) => void; title?: string; error?: string; + texts: typeof WIDGET_TEXTS; } export function FileDropzone(props: IDropzoneProps) { @@ -21,6 +23,7 @@ export function FileDropzone(props: IDropzoneProps) { loading, onReject, accept = [MIME_TYPES.png, MIME_TYPES.jpeg, MIME_TYPES.webp], + texts, } = props; const { classes } = useStyles(); @@ -38,13 +41,13 @@ export function FileDropzone(props: IDropzoneProps) { - {TEXTS.FILE_DROPZONE.TITLE}{' '} + {texts.FILE_DROP_AREA.DROP_FILE}{' '} - {TEXTS.FILE_DROPZONE.BROWSE} + {texts.FILE_DROP_AREA.BROWSE_FILE} - {TEXTS.FILE_DROPZONE.FILE_SIZE} + {texts.FILE_DROP_AREA.IMAGE_FILE_SIZE} ); diff --git a/apps/widget/src/design-system/UploadDropzone/UploadDropzone.tsx b/apps/widget/src/design-system/UploadDropzone/UploadDropzone.tsx index 81a0f8c6e..9ca73a7d0 100644 --- a/apps/widget/src/design-system/UploadDropzone/UploadDropzone.tsx +++ b/apps/widget/src/design-system/UploadDropzone/UploadDropzone.tsx @@ -1,9 +1,10 @@ import { Group, Text } from '@mantine/core'; import { Dropzone as MantineDropzone, FileWithPath, MIME_TYPES } from '@mantine/dropzone'; +import { WIDGET_TEXTS } from '@impler/shared'; import useStyles from './UploadDropzone.styles'; -import { TEXTS } from '../../config/texts.config'; import { FileIcon, CheckIcon } from '../../icons'; import { File as FileCMP } from '../File'; +import { colors } from '@config'; interface IDropzoneProps { loading?: boolean; @@ -15,6 +16,7 @@ interface IDropzoneProps { title?: string; error?: string; className?: string; + texts: typeof WIDGET_TEXTS; } export function UploadDropzone(props: IDropzoneProps) { @@ -28,6 +30,7 @@ export function UploadDropzone(props: IDropzoneProps) { title, className, error, + texts, } = props; const { classes } = useStyles({ hasError: !!error }); const wrapperClasses = [classes.wrapper]; @@ -44,7 +47,7 @@ export function UploadDropzone(props: IDropzoneProps) { - {TEXTS.FILE_DROPZONE.FILE_SELECTION} + {texts.FILE_DROP_AREA.FILE_SELECTED} {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} @@ -60,9 +63,9 @@ export function UploadDropzone(props: IDropzoneProps) {
- {TEXTS.FILE_DROPZONE.TITLE}{' '} + {texts.FILE_DROP_AREA.DROP_FILE}{' '} - {TEXTS.FILE_DROPZONE.BROWSE} + {texts.FILE_DROP_AREA.BROWSE_FILE} @@ -71,7 +74,7 @@ export function UploadDropzone(props: IDropzoneProps) { - {TEXTS.FILE_DROPZONE.SUBTITLE} + {texts.FILE_DROP_AREA.BRING_FILE}
@@ -82,13 +85,13 @@ export function UploadDropzone(props: IDropzoneProps) { return (
{title ? ( - + {title} ) : null} {isFileSelected ? : } {error ? ( - + {error} ) : null} diff --git a/apps/widget/src/hooks/Phase1/usePhase1.ts b/apps/widget/src/hooks/Phase1/usePhase1.ts index 1e71c6b24..fcb61ee25 100644 --- a/apps/widget/src/hooks/Phase1/usePhase1.ts +++ b/apps/widget/src/hooks/Phase1/usePhase1.ts @@ -7,7 +7,7 @@ import { notifier, ParentWindow } from '@util'; import { useAPIState } from '@store/api.context'; import { useAppState } from '@store/app.context'; import { useImplerState } from '@store/impler.context'; -import { IErrorObject, ITemplate, IUpload, FileMimeTypesEnum } from '@impler/shared'; +import { IErrorObject, ITemplate, IUpload, FileMimeTypesEnum, WIDGET_TEXTS } from '@impler/shared'; import { variables } from '@config'; import { useSample } from '@hooks/useSample'; @@ -16,9 +16,10 @@ import { IFormvalues, IUploadValues } from '@types'; interface IUsePhase1Props { goNext: () => void; + texts: typeof WIDGET_TEXTS; } -export function usePhase1({ goNext }: IUsePhase1Props) { +export function usePhase1({ goNext, texts }: IUsePhase1Props) { const { control, register, @@ -26,6 +27,7 @@ export function usePhase1({ goNext }: IUsePhase1Props) { setValue, setError, getValues, + resetField, handleSubmit, formState: { errors }, } = useForm(); @@ -47,26 +49,27 @@ export function usePhase1({ goNext }: IUsePhase1Props) { goNext(); }, onError(error: IErrorObject) { - notifier.showError({ title: error.error, message: error.message }); + resetField('file'); + setError('file', { type: 'file', message: error.message }); }, } ); - const { mutate: getExcelSheetNames } = useMutation( - ['getExcelSheetNames'], - (file) => api.getExcelSheetNames(file), - { - onSuccess(sheetNames) { - if (sheetNames.length <= 1) { - setValue('selectedSheetName', sheetNames[0]); - handleSubmit(uploadFile)(); - } else setExcelSheetNames(sheetNames); - }, - onError(error: IErrorObject) { - notifier.showError({ title: error.error, message: error.message }); - }, - } - ); + const { mutate: getExcelSheetNames, isLoading: isExcelSheetNamesLoading } = useMutation< + string[], + IErrorObject, + { file: File } + >(['getExcelSheetNames'], (file) => api.getExcelSheetNames(file), { + onSuccess(sheetNames) { + if (sheetNames.length <= 1) { + setValue('selectedSheetName', sheetNames[0]); + handleSubmit(uploadFile)(); + } else setExcelSheetNames(sheetNames); + }, + onError(error: IErrorObject) { + notifier.showError({ title: error.error, message: error.message }); + }, + }); const findTemplate = (): ITemplate | undefined => { let foundTemplate: ITemplate | undefined; @@ -74,8 +77,9 @@ export function usePhase1({ goNext }: IUsePhase1Props) { if (selectedTemplateValue && templates) { foundTemplate = templates.find((templateItem) => templateItem._id === selectedTemplateValue); } - if (!foundTemplate) notifier.showError('TEMPLATE_NOT_FOUND'); - else if (foundTemplate.totalColumns === variables.baseIndex) notifier.showError('INCOMPLETE_TEMPLATE'); + if (!foundTemplate) notifier.showError({ title: texts.COMMON.SORRY, message: texts.PHASE1.TEMPLATE_NOT_FOUND_MSG }); + else if (foundTemplate.totalColumns === variables.baseIndex) + notifier.showError({ title: texts.COMMON.SORRY, message: texts.PHASE1.INCOMPLETE_TEMPLATE_MSG }); else return foundTemplate; return undefined; @@ -109,6 +113,7 @@ export function usePhase1({ goNext }: IUsePhase1Props) { setIsDownloadInProgress(false); }; const uploadFile = async (submitData: IFormvalues) => { + setExcelSheetNames([]); const foundTemplate = findTemplate(); if (foundTemplate) { submitData.templateId = foundTemplate._id; @@ -156,6 +161,7 @@ export function usePhase1({ goNext }: IUsePhase1Props) { onTemplateChange, isDownloadInProgress, onSelectSheetModalReset, + isExcelSheetNamesLoading, showSelectTemplate: !templateId, onSelectExcelSheet: handleSubmit(uploadFile), }; diff --git a/apps/widget/src/hooks/useSample.ts b/apps/widget/src/hooks/useSample.ts index 43fdb3931..aeb70d7b7 100644 --- a/apps/widget/src/hooks/useSample.ts +++ b/apps/widget/src/hooks/useSample.ts @@ -65,9 +65,9 @@ export function useSample({ onDownloadComplete }: UseSampleProps) { isMultiSelect = parsedSchema.some( (schemaItem) => schemaItem.type === ColumnTypesEnum.SELECT && schemaItem.allowMultiSelect ); - sampleData.append('schema', new Blob([schema || ''], { type: 'application/json' })); + sampleData.append('schema', JSON.stringify(parsedSchema)); } - if (Array.isArray(data)) sampleData.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })); + if (data) sampleData.append('data', data); if (images && importId && imageSchema) { const imagesBlob = await images.generateAsync({ type: 'blob', compression: 'DEFLATE' }); sampleData.append('file', imagesBlob); diff --git a/apps/widget/src/store/app.context.tsx b/apps/widget/src/store/app.context.tsx index 4d81634ac..9caf93c54 100644 --- a/apps/widget/src/store/app.context.tsx +++ b/apps/widget/src/store/app.context.tsx @@ -25,6 +25,7 @@ const AppContextProvider = ({ children, primaryColor, title, + texts, data, schema, output, @@ -48,6 +49,7 @@ const AppContextProvider = ({ []; + data?: string; templateInfo: ITemplate; uploadInfo: IUpload; reset: () => void; diff --git a/apps/widget/src/util/helpers/common.helpers.ts b/apps/widget/src/util/helpers/common.helpers.ts index 0f26e4307..ec912fc8a 100644 --- a/apps/widget/src/util/helpers/common.helpers.ts +++ b/apps/widget/src/util/helpers/common.helpers.ts @@ -4,7 +4,7 @@ import tippy from 'tippy.js'; import 'tippy.js/dist/tippy.css'; import 'tippy.js/animations/shift-away.css'; import { variables } from '@config'; -import { downloadFile } from '@impler/shared'; +import { convertStringToJson, downloadFile, isObject, WIDGET_TEXTS } from '@impler/shared'; // eslint-disable-next-line no-magic-numbers export function formatBytes(bytes, decimals = 2) { @@ -96,3 +96,29 @@ export const addTippyToElement = (element: SVGSVGElement | HTMLElement, content: animation: 'shift-away', }); }; + +// Utility function to deeply merge defaultTexts with user provided texts +export function deepMerge( + defaultTexts: typeof WIDGET_TEXTS, + texts?: string | typeof WIDGET_TEXTS +): typeof WIDGET_TEXTS { + if (!texts) return defaultTexts; + let newTexts: typeof WIDGET_TEXTS | undefined; + if (typeof texts === 'string') newTexts = convertStringToJson(texts); + else newTexts = texts; + if (newTexts && !isObject(newTexts)) return defaultTexts; + else { + const mergedResult = { ...defaultTexts }; + for (const sectionKey in newTexts) { + if (isObject(newTexts[sectionKey])) { + for (const textKey in newTexts[sectionKey]) { + if (mergedResult[sectionKey][textKey] && typeof newTexts[sectionKey][textKey] === 'string') { + mergedResult[sectionKey][textKey] = newTexts[sectionKey][textKey]; + } + } + } + } + + return mergedResult; + } +} diff --git a/apps/widget/src/util/notifier/Notifier.tsx b/apps/widget/src/util/notifier/Notifier.tsx index c60877af3..97799d66e 100644 --- a/apps/widget/src/util/notifier/Notifier.tsx +++ b/apps/widget/src/util/notifier/Notifier.tsx @@ -1,17 +1,12 @@ -import { colors, TEXTS, ENV, SENTRY_DSN } from '@config'; -import { NotificationContent } from '@types'; -import { showNotification } from '@mantine/notifications'; import { captureMessage } from '@sentry/react'; +import { showNotification } from '@mantine/notifications'; + +import { colors, ENV, SENTRY_DSN } from '@config'; import { ENVTypesEnum } from '@impler/shared'; +import { NotificationContent } from '@types'; const autoCloseDuration = 5000; -export function showError(data: NotificationContent | keyof typeof TEXTS.NOTIFICATIONS) { - let notificationData: NotificationContent; - if (typeof data === 'string') { - notificationData = TEXTS.NOTIFICATIONS[data]; - } else { - notificationData = data; - } +export function showError(notificationData: NotificationContent) { showNotification({ color: '#FFFFFF', autoClose: autoCloseDuration, @@ -28,5 +23,5 @@ export function showError(data: NotificationContent | keyof typeof TEXTS.NOTIFIC }, }), }); - if (ENV === ENVTypesEnum.PROD && SENTRY_DSN) captureMessage(typeof data === 'string' ? data : data.message); + if (ENV === ENVTypesEnum.PROD && SENTRY_DSN) captureMessage(notificationData.message); } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 617656a1f..cd52f6d3b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,7 +17,7 @@ services: - impler api: privileged: true - image: "ghcr.io/implerhq/impler/api:0.23.0" + image: "ghcr.io/implerhq/impler/api:0.24.0" depends_on: - mongodb - rabbitmq @@ -50,7 +50,7 @@ services: networks: - impler queue-manager: - image: "ghcr.io/implerhq/impler/queue-manager:0.23.0" + image: "ghcr.io/implerhq/impler/queue-manager:0.24.0" depends_on: - api - rabbitmq @@ -60,7 +60,7 @@ services: MONGO_URL: ${MONGO_URL} RABBITMQ_CONN_URL: ${RABBITMQ_CONN_URL} S3_REGION: ${S3_REGION} - API_BASE_URL: ${API_BASE_URL} + API_ROOT_URL: ${API_ROOT_URL} S3_LOCAL_STACK: ${S3_LOCAL_STACK} S3_BUCKET_NAME: ${S3_BUCKET_NAME} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} @@ -74,7 +74,7 @@ services: networks: - impler widget: - image: "ghcr.io/implerhq/impler/widget:0.23.0" + image: "ghcr.io/implerhq/impler/widget:0.24.0" depends_on: - api container_name: widget @@ -90,7 +90,7 @@ services: embed: depends_on: - widget - image: "ghcr.io/implerhq/impler/embed:0.23.0" + image: "ghcr.io/implerhq/impler/embed:0.24.0" container_name: embed environment: WIDGET_URL: ${WIDGET_BASE_URL} @@ -101,7 +101,7 @@ services: web: depends_on: - api - image: "ghcr.io/implerhq/impler/web:0.23.0" + image: "ghcr.io/implerhq/impler/web:0.24.0" container_name: web environment: NEXT_PUBLIC_API_BASE_URL: ${API_ROOT_URL} diff --git a/lerna.json b/lerna.json index 54ba355ff..66cba6631 100644 --- a/lerna.json +++ b/lerna.json @@ -3,5 +3,5 @@ "npmClient": "pnpm", "useNx": true, "packages": ["apps/*", "libs/*", "packages/*"], - "version": "0.23.1" + "version": "0.24.0" } diff --git a/libs/dal/package.json b/libs/dal/package.json index 9cff0ba48..5d7d88b71 100644 --- a/libs/dal/package.json +++ b/libs/dal/package.json @@ -1,6 +1,6 @@ { "name": "@impler/dal", - "version": "0.23.1", + "version": "0.24.0", "author": "implerhq", "license": "MIT", "private": true, @@ -16,7 +16,7 @@ "lint:fix": "eslint src --fix" }, "dependencies": { - "@impler/shared": "^0.23.1", + "@impler/shared": "^0.24.0", "class-transformer": "^0.5.1", "date-fns": "^2.30.0", "mongoose": "8.0.1", diff --git a/libs/dal/src/repositories/user/user.entity.ts b/libs/dal/src/repositories/user/user.entity.ts index fc1195029..031655d82 100644 --- a/libs/dal/src/repositories/user/user.entity.ts +++ b/libs/dal/src/repositories/user/user.entity.ts @@ -37,5 +37,24 @@ export class UserEntity { @Exclude({ toPlainOnly: true }) resetTokenDate?: string; + @Exclude({ toPlainOnly: true }) resetTokenCount?: IUserResetTokenCount; + + @Exclude({ toPlainOnly: true }) + companySize?: string | null; + + @Exclude({ toPlainOnly: true }) + role?: string | null; + + @Exclude({ toPlainOnly: true }) + source?: string | null; + + @Exclude({ toPlainOnly: true }) + signupMethod?: string; + + @Exclude({ toPlainOnly: true }) + isEmailVerified: boolean; + + @Exclude({ toPlainOnly: true }) + verificationCode: string; } diff --git a/libs/dal/src/repositories/user/user.schema.ts b/libs/dal/src/repositories/user/user.schema.ts index 4f58b638e..80dea7f9a 100644 --- a/libs/dal/src/repositories/user/user.schema.ts +++ b/libs/dal/src/repositories/user/user.schema.ts @@ -11,6 +11,7 @@ const userSchema = new Schema( password: String, profilePicture: Schema.Types.String, showOnBoarding: Schema.Types.Boolean, + signupMethod: Schema.Types.String, tokens: [ { providerId: Schema.Types.String, @@ -24,6 +25,14 @@ const userSchema = new Schema( }, resetToken: Schema.Types.String, resetTokenDate: Schema.Types.Date, + companySize: Schema.Types.String, + role: Schema.Types.String, + source: Schema.Types.String, + isEmailVerified: { + type: Schema.Types.Boolean, + default: false, + }, + verificationCode: Schema.Types.String, }, schemaOptions ); diff --git a/libs/embed/package.json b/libs/embed/package.json index 39dc2b0a1..446ec3fed 100644 --- a/libs/embed/package.json +++ b/libs/embed/package.json @@ -1,6 +1,6 @@ { "name": "@impler/embed", - "version": "0.23.1", + "version": "0.24.0", "private": true, "license": "MIT", "author": "implerhq", diff --git a/libs/embed/src/embed.ts b/libs/embed/src/embed.ts index 06517b4e7..8a1907ab8 100644 --- a/libs/embed/src/embed.ts +++ b/libs/embed/src/embed.ts @@ -103,6 +103,7 @@ class Impler { }, '*' ); + this.hideWidget(); } isReady = () => this.isWidgetReady; diff --git a/libs/services/package.json b/libs/services/package.json index 75ca70cf1..6399052ed 100644 --- a/libs/services/package.json +++ b/libs/services/package.json @@ -1,6 +1,6 @@ { "name": "@impler/services", - "version": "0.23.1", + "version": "0.24.0", "description": "Reusable services to shared between backend api and queue-manager", "license": "MIT", "author": "implerhq", @@ -30,7 +30,7 @@ "@aws-sdk/client-ses": "^3.616.0", "@aws-sdk/lib-storage": "^3.360.0", "@aws-sdk/s3-request-presigner": "^3.276.0", - "@impler/shared": "^0.23.1", + "@impler/shared": "^0.24.0", "axios": "1.6.2", "nodemailer": "^6.9.14", "uuid": "^9.0.0" diff --git a/libs/services/src/email/email.service.ts b/libs/services/src/email/email.service.ts index b1e31e9b7..326528873 100644 --- a/libs/services/src/email/email.service.ts +++ b/libs/services/src/email/email.service.ts @@ -33,8 +33,93 @@ interface ISendMailOptions { interface IForgotPasswordEmailOptions { link: string; } +interface IVerificationEmailOptions { + otp: string; + firstName: string; +} const EMAIL_CONTENTS = { + VERIFICATION_EMAIL: ({ otp, firstName }: IVerificationEmailOptions) => ` + + + + + + + + +
+
+ Impler Logo +

Verification Code

+
+ +
+

Hi ${firstName},

+

Your OTP code is ${otp}. Please enter this code to verify your identity.

+

If you did not request this code, please ignore this email.

+

Thank you,

+

The Impler Team

+
+ + +
+ + + `, + REQUEST_FORGOT_PASSWORD: ({ link }: IForgotPasswordEmailOptions) => `

Hi,

You have requested to reset your password. Please click on the link below to reset your password.

@@ -326,6 +411,10 @@ type EmailContents = | { type: 'ERROR_EXECUTING_CODE'; data: IExecutionErrorEmailOptions; + } + | { + type: 'VERIFICATION_EMAIL'; + data: IVerificationEmailOptions; }; export abstract class EmailService { diff --git a/libs/services/src/payment/payment.api.service.ts b/libs/services/src/payment/payment.api.service.ts index dbd696a85..d71e2b70f 100644 --- a/libs/services/src/payment/payment.api.service.ts +++ b/libs/services/src/payment/payment.api.service.ts @@ -19,7 +19,7 @@ interface ICustomer { email: string; externalId: string; id: string; - currency: 'USD' | 'INR'; + currency?: 'USD' | 'INR'; } interface ICheckEvent { email: string; @@ -96,6 +96,17 @@ export class PaymentAPIService { return response.data; } + async getCustomer(externalId: string): Promise { + const url = `${this.PAYMENT_API_BASE_URL}/api/v1/customer/${externalId}`; + const response = await axios.get(url, { + headers: { + [this.AUTH_KEY]: this.AUTH_VALUE, + }, + }); + + return response.data; + } + async fetchActiveSubscription(email: string): Promise { if (!this.PAYMENT_API_BASE_URL) return; diff --git a/libs/shared/package.json b/libs/shared/package.json index 7d7599678..38439fbf0 100644 --- a/libs/shared/package.json +++ b/libs/shared/package.json @@ -1,6 +1,6 @@ { "name": "@impler/shared", - "version": "0.23.1", + "version": "0.24.0", "description": "Reusable types and classes to shared between apps and libraries", "license": "MIT", "author": "implerhq", diff --git a/libs/shared/src/config/index.ts b/libs/shared/src/config/index.ts index ce41b6a32..e7db5c857 100644 --- a/libs/shared/src/config/index.ts +++ b/libs/shared/src/config/index.ts @@ -1 +1,2 @@ export * from './api.config'; +export * from './texts.config'; diff --git a/libs/shared/src/config/texts.config.ts b/libs/shared/src/config/texts.config.ts new file mode 100644 index 000000000..cba0d5c4f --- /dev/null +++ b/libs/shared/src/config/texts.config.ts @@ -0,0 +1,93 @@ +export const WIDGET_TEXTS = { + COMMON: { + SORRY: 'Sorry!', + CLOSE_WIDGET: 'Close', + UPLOAD_AGAIN: 'Upload Again', + }, + STEPPER_TITLES: { + GENERATE_TEMPLATE: 'Generate Template', + UPLOAD_FILE: 'Upload', + MAP_COLUMNS: 'Map Columns', + REVIEW_DATA: 'Review', + COMPLETE_IMPORT: 'Complete', + CONFIGURE_JOB: 'Configure', + SCHEDULE_JOB: 'Schedule', + CONFIRM_JOB: 'Confirm', + }, + FILE_DROP_AREA: { + DROP_FILE: 'Drop and drop a file here or ', + BROWSE_FILE: 'Browse from computer', + IMAGE_FILE_SIZE: 'Image size should be less than 5 MB. Supported formats are PNG, JPG and JPEG.', + BRING_FILE: 'Bring any .csv or .xlsx file here to start Import', + FILE_SELECTED: 'File selected successfully', + }, + 'PHASE0-1': { + IMPORT_FILE: 'Import File', + GENERATE_TEMPLATE: 'Generate Template', + IMAGE_INFO_TITLE: 'Generate template with images', + IMAGE_INFO_SUBTITLE: + 'Drag and drop images below for image columns and generate a template file containing names of uploaded images.', + }, + PHASE1: { + SELECT_TEMPLATE_NAME: 'Template', + SELECT_TEMPLATE_NAME_PLACEHOLDER: 'Select Template', + SELECT_TEMPLATE_REQUIRED_MSG: 'Please select a template from the list', + + SELECT_SHEET_NAME: 'Select sheet to Import', + SELECT_SHEET_NAME_PLACEHOLDER: 'Select Excel sheet', + SELECT_SHEET_CONFIRM: 'Select', + SELECT_SHEET_REQUIRED_MSG: 'Please select sheet from the list', + + DOWNLOAD_SAMPLE: 'Download sample', + GENERATE_TEMPLATE: 'Generate Template', + SEE_MAPPING: 'See Mapping', + + SELECT_FILE_NAME: 'Select a file', + SELECT_FILE_REQUIRED_MSG: 'Please select a file', + SELECT_FILE_FORMAT_MSG: 'File type not supported! Please select a .csv or .xlsx file.', + + TEMPLATE_NOT_FOUND_MSG: "We couldn't find the template you're importing! Please check the passed parameters.", + INCOMPLETE_TEMPLATE_MSG: 'This import does not have any columns. Please try again after some time!', + }, + PHASE2: { + REVIEW_DATA: 'Review Data', + IN_SCHEMA_TITLE: 'Column in schema', + IN_SHEET_TITLE: 'Column in your sheet', + }, + PHASE3: { + EXPORT_DATA: 'Export Data', + RE_REVIEW_DATA: 'Re-Review Data', + COMPLETE: 'Complete', + ALL_RECORDS_VALID_TITLE: ' All records are found valid!', + ALL_RECORDS_VALID_DETAILS: 'All {total} row(s) found valid! Would you like to complete the Import?', + }, + PHASE4: { + TITLE: 'Bravo! {count} rows have been uploaded', + SUB_TITLE: '{count} rows have been uploaded successfully and currently is in process, it will be ready shortly.', + UPLOAD_AGAIN: 'Upload New File', + }, + AUTOIMPORT_PHASE1: { + MAPCOLUMN: 'Map Column', + }, + AUTOIMPORT_PHASE2: { + SCHEDULE: 'Schedule', + IN_SCHEMA_TITLE: 'Column in schema', + IN_FEED_TITLE: 'Key in your RSS feed ', + }, + AUTOIMPORT_PHASE3: { + CONFIRM: 'Confirm', + INVALID_CRON_MESSAGE: 'Expression values are incorrect. Please update values as per valid values below!', + }, + DELETE_RECORDS_CONFIRMATION: { + TITLE: `{total} rows will be deleted. Are you sure?`, + DETAILS: 'This action cannot be undone.', + CONFIRM_DELETE: 'Yes', + CANCEL_DELETE: 'Cancel', + }, + CLOSE_CONFIRMATION: { + TITLE: `Are you sure? You will lose your work in progress.`, + DETAILS: 'Your import is in progress, clicking Yes will reset it.', + CONFIRM_CLOSE: 'Yes', + CANCEL_CLOSE: 'No', + }, +}; diff --git a/libs/shared/src/types/auth/auth.types.ts b/libs/shared/src/types/auth/auth.types.ts index e12767f07..f5b059cc5 100644 --- a/libs/shared/src/types/auth/auth.types.ts +++ b/libs/shared/src/types/auth/auth.types.ts @@ -1,3 +1,5 @@ +import { SCREENS } from '../../utils/defaults'; + export enum AuthProviderEnum { 'GOOGLE' = 'google', 'GITHUB' = 'github', @@ -6,14 +8,21 @@ export enum AuthProviderEnum { export interface IJwtPayload { _id: string; _projectId?: string; - role?: string; email?: string; firstName?: string; lastName?: string; profilePicture?: string; + companySize?: string; + role?: string; + source?: string; + isEmailVerified: boolean; } export interface ILoginResponse { token: string; - showAddProject?: boolean; + screen?: SCREENS; +} + +export interface IScreenResponse { + screen: SCREENS; } diff --git a/libs/shared/src/types/bilablemetriccode/billablemetriccode.types.ts b/libs/shared/src/types/bilablemetric-code/billablemetriccode.types.ts similarity index 100% rename from libs/shared/src/types/bilablemetriccode/billablemetriccode.types.ts rename to libs/shared/src/types/bilablemetric-code/billablemetriccode.types.ts diff --git a/libs/shared/src/types/index.ts b/libs/shared/src/types/index.ts index e78d6d018..de84bf816 100644 --- a/libs/shared/src/types/index.ts +++ b/libs/shared/src/types/index.ts @@ -12,4 +12,4 @@ export * from './subscription'; export * from './template'; export * from './import-job/import-job.types'; export * from './user-job/user-job.types'; -export * from './bilablemetriccode/billablemetriccode.types'; +export * from './bilablemetric-code/billablemetriccode.types'; diff --git a/libs/shared/src/types/widget/widget.types.ts b/libs/shared/src/types/widget/widget.types.ts index 4c7025847..4d38fe6c1 100644 --- a/libs/shared/src/types/widget/widget.types.ts +++ b/libs/shared/src/types/widget/widget.types.ts @@ -1,18 +1,32 @@ -export interface IShowPayload { +import { WIDGET_TEXTS } from '../../config/texts.config'; +import { ISchemaItem } from '../column'; + +export interface ICommonShowPayload { host: string; - extra?: string; + extra?: string | any; templateId?: string; authHeaderValue?: string; primaryColor?: string; colorScheme?: string; title?: string; - schema?: string; - data?: Record[]; - output?: string; projectId: string; accessToken: string; uuid: string; } +export interface IWidgetShowPayload extends ICommonShowPayload { + texts?: typeof WIDGET_TEXTS; + data?: string; + schema?: string; + output?: string; +} + +export interface IUserShowPayload extends ICommonShowPayload { + texts?: string | typeof WIDGET_TEXTS; + data?: string | Record[]; + schema?: string | ISchemaItem[]; + output?: string | Record; +} + export interface IOption { value: string; label: string; diff --git a/libs/shared/src/utils/defaults.ts b/libs/shared/src/utils/defaults.ts index ae4b509e8..ef2793bb0 100644 --- a/libs/shared/src/utils/defaults.ts +++ b/libs/shared/src/utils/defaults.ts @@ -68,3 +68,19 @@ export const DEFAULT_KEYS_OBJ = { 'Boolean false': '<>', 'UUID v4': '<>', }; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum SCREENS { + VERIFY = 'verify', + ONBOARD = 'onboard', + HOME = 'home', +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum EMAIL_SUBJECT { + ERROR_SENDING_BUBBLE_DATA = '🛑 Encountered error while sending data to Bubble in', + ERROR_EXECUTING_VALIDATION_CODE = '🛑 Encountered error while executing validation code in', + ERROR_SENDING_WEBHOOK_DATA = '🛑 Encountered error while sending webhook data in', + VERIFICATION_CODE = 'Your Verification Code for Impler', + RESET_PASSWORD = 'Reset Password | Impler', +} diff --git a/libs/shared/src/utils/helpers.ts b/libs/shared/src/utils/helpers.ts index e6e51f953..fc6082b82 100644 --- a/libs/shared/src/utils/helpers.ts +++ b/libs/shared/src/utils/helpers.ts @@ -73,3 +73,15 @@ export function constructQueryString(obj: Record): stri return query ? `?${query}` : ''; } + +export const isObject = (value: any) => + typeof value === 'object' && !Array.isArray(value) && value !== null && Object.keys(value).length > 0; + +export const convertStringToJson = (value: any) => { + if (isObject(value)) return value; + try { + return JSON.parse(value); + } catch (e) { + return undefined; + } +}; diff --git a/package.json b/package.json index da0474225..46d31e25f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impler.io", - "version": "0.22.0", + "version": "0.24.0", "description": "Open source infrastructure to import data easily", "packageManager": "pnpm@8.9.0", "private": true, diff --git a/packages/client/package.json b/packages/client/package.json index 8410b3b84..3fb03ad73 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@impler/client", - "version": "0.23.1", + "version": "0.24.0", "description": "API client to be used in end user environments", "license": "MIT", "author": "implerhq", @@ -37,7 +37,7 @@ "typescript": "4.8.4" }, "dependencies": { - "@impler/shared": "^0.23.1", + "@impler/shared": "^0.24.0", "axios": "1.6.2" }, "engines": { diff --git a/packages/react/package.json b/packages/react/package.json index 9ee1b89f3..02789c3c6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@impler/react", - "version": "0.23.1", + "version": "0.24.0", "description": "React library to show widget in client applications", "license": "MIT", "author": "implerhq", @@ -53,6 +53,6 @@ "typescript": "^4.4.4" }, "dependencies": { - "@impler/shared": "^0.23.1" + "@impler/shared": "^0.24.0" } } diff --git a/packages/react/src/hooks/useImpler.ts b/packages/react/src/hooks/useImpler.ts index f7285e68d..cc99ac779 100644 --- a/packages/react/src/hooks/useImpler.ts +++ b/packages/react/src/hooks/useImpler.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { EventTypesEnum, IShowPayload } from '@impler/shared'; +import { EventTypesEnum, IUserShowPayload, isObject } from '@impler/shared'; import { logError } from '../utils/logger'; import { EventCalls, ShowWidgetProps, UseImplerProps } from '../types'; @@ -11,6 +11,7 @@ export function useImpler({ accessToken, authHeaderValue, title, + texts, extra, onUploadComplete, onWidgetClose, @@ -76,31 +77,25 @@ export function useImpler({ const showWidget = async ({ colorScheme, data, schema, output }: ShowWidgetProps = {}) => { if (window.impler && isImplerInitiated) { - const payload: IShowPayload = { + const payload: IUserShowPayload = { uuid, templateId, - data, host: '', projectId, accessToken, + schema, + data, + output, + title, + extra, + colorScheme, + primaryColor, }; - if (Array.isArray(schema) && schema.length > 0) { - payload.schema = JSON.stringify(schema); - } - if (typeof output === 'object' && !Array.isArray(output) && output !== null) { - payload.output = JSON.stringify(output); - } - if (title) payload.title = title; - if (colorScheme) payload.colorScheme = colorScheme; - else { + if (!colorScheme) { const preferColorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; payload.colorScheme = preferColorScheme; } - if (primaryColor) payload.primaryColor = primaryColor; - if (extra) { - if (typeof extra === 'object') payload.extra = JSON.stringify(extra); - else payload.extra = extra; - } + if (isObject(texts)) payload.texts = JSON.stringify(texts); if (authHeaderValue) { if (typeof authHeaderValue === 'function' && authHeaderValue.constructor.name === 'AsyncFunction') { payload.authHeaderValue = await authHeaderValue(); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4cc90d02b..ff73828ea 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1 +1,3 @@ export * from './hooks'; +export type { ColumnTypesEnum } from '@impler/shared'; +export type { ISchemaItem, CustomTexts } from './types'; diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts index 0a53787fc..420c44650 100644 --- a/packages/react/src/types/index.ts +++ b/packages/react/src/types/index.ts @@ -1,19 +1,4 @@ -import { IUpload, EventTypesEnum } from '@impler/shared'; - -export interface ButtonProps { - projectId: string; - accessToken?: string; - templateId?: string; - authHeaderValue?: string | (() => string) | (() => Promise); - extra?: string | Record; - children?: React.ReactNode; - className?: string; - primaryColor?: string; - onUploadStart?: (value: UploadTemplateData) => void; - onUploadTerminate?: (value: UploadData) => void; - onUploadComplete?: (value: IUpload) => void; - onWidgetClose?: () => void; -} +import { IUpload, EventTypesEnum, WIDGET_TEXTS, ColumnTypesEnum } from '@impler/shared'; export interface ISchemaItem { key: string; @@ -26,7 +11,7 @@ export interface ISchemaItem { defaultValue?: string | '<>' | '<>' | '<<>>' | '<<[]>>' | '<>' | '<>'; selectValues?: string[]; dateFormats?: string[]; - type?: 'String' | 'Number' | 'Double' | 'Date' | 'Email' | 'Regex' | 'Select' | 'Any' | 'Image'; + type?: ColumnTypesEnum; regex?: string; allowMultiSelect?: boolean; } @@ -68,8 +53,17 @@ export interface ShowWidgetProps { output?: Record; } +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +export type CustomTexts = DeepPartial; + export interface UseImplerProps { title?: string; + texts?: CustomTexts; projectId?: string; templateId?: string; accessToken?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e296487b..d113d90ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,13 +90,13 @@ importers: apps/api: dependencies: '@impler/dal': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/dal '@impler/services': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/services '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/shared '@nestjs/common': specifier: ^9.1.2 @@ -310,13 +310,13 @@ importers: apps/queue-manager: dependencies: '@impler/dal': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/dal '@impler/services': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/services '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/shared '@sentry/node': specifier: ^7.112.2 @@ -498,10 +498,10 @@ importers: specifier: ^14.1.0 version: 14.4.0(handsontable@14.4.0) '@impler/client': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../packages/client '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/shared '@mantine/core': specifier: 6.0.21 @@ -523,7 +523,7 @@ importers: version: 6.5.16(@babel/core@7.24.7)(eslint@8.57.0)(react-dom@18.2.0)(react@18.2.0)(typescript@4.9.5)(webpack@5.92.1) '@storybook/react': specifier: ^6.5.13 - version: 6.5.16(@babel/core@7.24.7)(eslint@8.57.0)(react-dom@18.2.0)(react@18.2.0)(require-from-string@2.0.2)(typescript@4.9.5)(webpack-dev-server@4.15.2) + version: 6.5.16(@babel/core@7.24.7)(@storybook/builder-webpack5@6.5.16)(@storybook/manager-webpack5@6.5.16)(eslint@8.57.0)(react-dom@18.2.0)(react@18.2.0)(require-from-string@2.0.2)(typescript@4.9.5) '@tanstack/react-query': specifier: ^4.14.5 version: 4.36.1(react-dom@18.2.0)(react@18.2.0) @@ -548,15 +548,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 - moment: - specifier: ^2.29.4 - version: 2.30.1 react: specifier: 18.2.0 version: 18.2.0 - react-datepicker: - specifier: ^4.21.0 - version: 4.25.0(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -581,10 +575,10 @@ importers: webfontloader: specifier: ^1.6.28 version: 1.6.28 - webpack-dev-server: - specifier: ^4.11.1 - version: 4.15.2(webpack@5.92.1) devDependencies: + '@babel/preset-typescript': + specifier: ^7.24.7 + version: 7.24.7(@babel/core@7.24.7) '@types/file-saver': specifier: ^2.0.5 version: 2.0.7 @@ -597,6 +591,9 @@ importers: '@types/react-dom': specifier: ^18.2.0 version: 18.2.0 + ts-loader: + specifier: ^9.4.1 + version: 9.5.1(typescript@4.9.5)(webpack@5.92.1) typescript: specifier: ^4.8.3 version: 4.9.5 @@ -604,7 +601,7 @@ importers: libs/dal: dependencies: '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../shared class-transformer: specifier: ^0.5.1 @@ -716,7 +713,7 @@ importers: specifier: ^3.276.0 version: 3.609.0 '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../shared axios: specifier: 1.6.2 @@ -763,7 +760,7 @@ importers: packages/client: dependencies: '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/shared axios: specifier: 1.6.2 @@ -785,7 +782,7 @@ importers: packages/react: dependencies: '@impler/shared': - specifier: ^0.23.1 + specifier: ^0.24.0 version: link:../../libs/shared react: specifier: '>=16.8.0' @@ -2581,7 +2578,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.9 - '@babel/helper-plugin-utils': 7.10.4 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.12.9) '@babel/plugin-transform-parameters': 7.24.7(@babel/core@7.12.9) @@ -9013,98 +9010,6 @@ packages: - webpack-dev-server - webpack-hot-middleware - webpack-plugin-serve - dev: true - - /@storybook/react@6.5.16(@babel/core@7.24.7)(eslint@8.57.0)(react-dom@18.2.0)(react@18.2.0)(require-from-string@2.0.2)(typescript@4.9.5)(webpack-dev-server@4.15.2): - resolution: - { integrity: sha512-cBtNlOzf/MySpNLBK22lJ8wFU22HnfTB2xJyBk7W7Zi71Lm7Uxkhv1Pz8HdiQndJ0SlsAAQOWjQYsSZsGkZIaA== } - engines: { node: '>=10.13.0' } - hasBin: true - peerDependencies: - '@babel/core': ^7.11.5 - '@storybook/builder-webpack4': '*' - '@storybook/builder-webpack5': '*' - '@storybook/manager-webpack4': '*' - '@storybook/manager-webpack5': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - require-from-string: ^2.0.2 - typescript: '*' - peerDependenciesMeta: - '@babel/core': - optional: true - '@storybook/builder-webpack4': - optional: true - '@storybook/builder-webpack5': - optional: true - '@storybook/manager-webpack4': - optional: true - '@storybook/manager-webpack5': - optional: true - typescript: - optional: true - dependencies: - '@babel/core': 7.24.7 - '@babel/preset-flow': 7.24.7(@babel/core@7.24.7) - '@babel/preset-react': 7.24.7(@babel/core@7.24.7) - '@pmmmwh/react-refresh-webpack-plugin': 0.5.15(react-refresh@0.11.0)(webpack-dev-server@4.15.2)(webpack@5.92.1) - '@storybook/addons': 6.5.16(react-dom@18.2.0)(react@18.2.0) - '@storybook/client-logger': 6.5.16 - '@storybook/core': 6.5.16(@storybook/builder-webpack5@6.5.16)(@storybook/manager-webpack5@6.5.16)(eslint@8.57.0)(react-dom@18.2.0)(react@18.2.0)(typescript@4.9.5)(webpack@5.92.1) - '@storybook/core-common': 6.5.16(eslint@8.57.0)(react-dom@18.2.0)(react@18.2.0)(typescript@4.9.5) - '@storybook/csf': 0.0.2--canary.4566f4d.1 - '@storybook/docs-tools': 6.5.16(react-dom@18.2.0)(react@18.2.0) - '@storybook/node-logger': 6.5.16 - '@storybook/react-docgen-typescript-plugin': 1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0(typescript@4.9.5)(webpack@5.92.1) - '@storybook/semver': 7.3.2 - '@storybook/store': 6.5.16(react-dom@18.2.0)(react@18.2.0) - '@types/estree': 0.0.51 - '@types/node': 16.18.101 - '@types/webpack-env': 1.18.5 - acorn: 7.4.1 - acorn-jsx: 5.3.2(acorn@7.4.1) - acorn-walk: 7.2.0 - babel-plugin-add-react-displayname: 0.0.5 - babel-plugin-react-docgen: 4.2.1 - core-js: 3.37.1 - escodegen: 2.1.0 - fs-extra: 9.1.0 - global: 4.4.0 - html-tags: 3.3.1 - lodash: 4.17.21 - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-element-to-jsx-string: 14.3.4(react-dom@18.2.0)(react@18.2.0) - react-refresh: 0.11.0 - read-pkg-up: 7.0.1 - regenerator-runtime: 0.13.11 - require-from-string: 2.0.2 - ts-dedent: 2.2.0 - typescript: 4.9.5 - util-deprecate: 1.0.2 - webpack: 5.92.1 - transitivePeerDependencies: - - '@storybook/mdx2-csf' - - '@swc/core' - - '@types/webpack' - - bluebird - - bufferutil - - encoding - - esbuild - - eslint - - sockjs-client - - supports-color - - type-fest - - uglify-js - - utf-8-validate - - vue-template-compiler - - webpack-cli - - webpack-command - - webpack-dev-server - - webpack-hot-middleware - - webpack-plugin-serve - dev: false /@storybook/router@6.5.16(react-dom@18.2.0)(react@18.2.0): resolution: @@ -12301,6 +12206,7 @@ packages: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + dev: true /chalk@4.1.2: resolution: @@ -12500,11 +12406,6 @@ packages: validator: 13.12.0 dev: false - /classnames@2.5.1: - resolution: - { integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== } - dev: false - /clean-css@4.2.4: resolution: { integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A== } @@ -18069,7 +17970,7 @@ packages: hasBin: true dependencies: async: 3.2.5 - chalk: 4.1.0 + chalk: 4.1.2 filelist: 1.0.4 minimatch: 3.1.2 @@ -23285,23 +23186,6 @@ packages: react: 18.2.0 dev: false - /react-datepicker@4.25.0(react-dom@18.2.0)(react@18.2.0): - resolution: - { integrity: sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg== } - peerDependencies: - react: ^16.9.0 || ^17 || ^18 - react-dom: ^16.9.0 || ^17 || ^18 - dependencies: - '@popperjs/core': 2.11.8 - classnames: 2.5.1 - date-fns: 2.30.0 - prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-onclickoutside: 6.13.1(react-dom@18.2.0)(react@18.2.0) - react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) - dev: false - /react-dev-utils@12.0.1(eslint@8.57.0)(typescript@4.9.5)(webpack@5.92.1): resolution: { integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ== } @@ -23449,6 +23333,7 @@ packages: /react-fast-compare@3.2.2: resolution: { integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== } + dev: true /react-hook-form@7.52.1(react@18.2.0): resolution: @@ -23484,17 +23369,6 @@ packages: resolution: { integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== } - /react-onclickoutside@6.13.1(react-dom@18.2.0)(react@18.2.0): - resolution: - { integrity: sha512-LdrrxK/Yh9zbBQdFbMTXPp3dTSN9B+9YJQucdDu3JNKRrbdU+H+/TVONJoWtOwy4II8Sqf1y/DTI6w/vGPYW0w== } - peerDependencies: - react: ^15.5.x || ^16.x || ^17.x || ^18.x - react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x - dependencies: - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0): resolution: { integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q== } @@ -23508,6 +23382,7 @@ packages: react-dom: 18.2.0(react@18.2.0) react-fast-compare: 3.2.2 warning: 4.0.3 + dev: true /react-property@2.0.0: resolution: @@ -27307,6 +27182,7 @@ packages: { integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== } dependencies: loose-envify: 1.4.0 + dev: true /watchpack-chokidar2@2.0.1: resolution: