Refactor and clean up BFF and portal components for improved maintainability
- Removed deprecated files and components from the BFF application, including various auth and catalog services, enhancing code clarity. - Updated package.json scripts for better organization and streamlined development processes. - Refactored portal components to improve structure and maintainability, including the removal of unused files and components. - Enhanced type definitions and imports across the application for consistency and clarity.
This commit is contained in:
parent
ed6fae677d
commit
a22b84f128
@ -1900,11 +1900,11 @@
|
||||
"AddressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"line1": {
|
||||
"street": {
|
||||
"type": "string",
|
||||
"example": "123 Main Street"
|
||||
},
|
||||
"line2": {
|
||||
"streetLine2": {
|
||||
"type": "string",
|
||||
"example": "Apt 4B"
|
||||
},
|
||||
@ -1927,7 +1927,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"line1",
|
||||
"street",
|
||||
"city",
|
||||
"state",
|
||||
"postalCode",
|
||||
|
||||
@ -12,15 +12,14 @@
|
||||
"dev": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
|
||||
"start:debug": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint .",
|
||||
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint . --fix",
|
||||
"test": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest",
|
||||
"test:watch": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --watch",
|
||||
"test:cov": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --config ./test/jest-e2e.json",
|
||||
"type-check": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit",
|
||||
"type-check:incremental": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --incremental",
|
||||
"clean": "rm -rf dist",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
import { Controller, Post, Body, UseGuards, Get, Req, HttpCode } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
|
||||
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
import { ChangePasswordDto } from "./dto/change-password.dto";
|
||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||
import { ValidateSignupDto } from "./dto/validate-signup.dto";
|
||||
import { AccountStatusRequestDto, AccountStatusResponseDto } from "./dto/account-status.dto";
|
||||
import { Public } from "./decorators/public.decorator";
|
||||
import { SsoLinkDto } from "./dto/sso-link.dto";
|
||||
import { SignupDto } from "./dto/signup.dto";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post("validate-signup")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP
|
||||
@ApiOperation({ summary: "Validate customer number for signup" })
|
||||
@ApiResponse({ status: 200, description: "Validation successful" })
|
||||
@ApiResponse({ status: 409, description: "Customer already has account" })
|
||||
@ApiResponse({ status: 400, description: "Customer number not found" })
|
||||
@ApiResponse({ status: 429, description: "Too many validation attempts" })
|
||||
async validateSignup(@Body() validateDto: ValidateSignupDto, @Req() req: Request) {
|
||||
return this.authService.validateSignup(validateDto, req);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get("health-check")
|
||||
@ApiOperation({ summary: "Check auth service health and integrations" })
|
||||
@ApiResponse({ status: 200, description: "Health check results" })
|
||||
async healthCheck() {
|
||||
return this.authService.healthCheck();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("signup-preflight")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@Throttle({ default: { limit: 10, ttl: 900000 } })
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Validate full signup data without creating anything" })
|
||||
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
|
||||
async signupPreflight(@Body() body: SignupDto) {
|
||||
return this.authService.signupPreflight(body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("account-status")
|
||||
@ApiOperation({ summary: "Get account status by email" })
|
||||
@ApiOkResponse({ description: "Account status" })
|
||||
async accountStatus(@Body() body: AccountStatusRequestDto): Promise<AccountStatusResponseDto> {
|
||||
return this.authService.getAccountStatus(body.email);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("signup")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 signups per 15 minutes per IP
|
||||
@ApiOperation({ summary: "Create new user account" })
|
||||
@ApiResponse({ status: 201, description: "User created successfully" })
|
||||
@ApiResponse({ status: 409, description: "User already exists" })
|
||||
@ApiResponse({ status: 429, description: "Too many signup attempts" })
|
||||
async signup(@Body() signupDto: SignupDto, @Req() req: Request) {
|
||||
return this.authService.signup(signupDto, req);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post("login")
|
||||
@ApiOperation({ summary: "Authenticate user" })
|
||||
@ApiResponse({ status: 200, description: "Login successful" })
|
||||
@ApiResponse({ status: 401, description: "Invalid credentials" })
|
||||
async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
|
||||
return this.authService.login(req.user, req);
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@ApiOperation({ summary: "Logout user" })
|
||||
@ApiResponse({ status: 200, description: "Logout successful" })
|
||||
async logout(@Req() req: Request & { user: { id: string } }) {
|
||||
const authHeader = req.headers.authorization as string | string[] | undefined;
|
||||
let bearer: string | undefined;
|
||||
if (typeof authHeader === "string") {
|
||||
bearer = authHeader;
|
||||
} else if (Array.isArray(authHeader) && authHeader.length > 0) {
|
||||
bearer = authHeader[0];
|
||||
}
|
||||
const token = bearer?.startsWith("Bearer ") ? bearer.slice(7) : undefined;
|
||||
await this.authService.logout(req.user.id, token ?? "", req);
|
||||
return { message: "Logout successful" };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("link-whmcs")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 attempts per 15 minutes per IP
|
||||
@ApiOperation({ summary: "Link existing WHMCS user" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "WHMCS account linked successfully",
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
|
||||
@ApiResponse({ status: 429, description: "Too many link attempts" })
|
||||
async linkWhmcs(@Body() linkDto: LinkWhmcsDto, @Req() req: Request) {
|
||||
return this.authService.linkWhmcsUser(linkDto, req);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("set-password")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@Throttle({ default: { limit: 3, ttl: 300000 } }) // 3 attempts per 5 minutes per IP
|
||||
@ApiOperation({ summary: "Set password for linked user" })
|
||||
@ApiResponse({ status: 200, description: "Password set successfully" })
|
||||
@ApiResponse({ status: 401, description: "User not found" })
|
||||
@ApiResponse({ status: 429, description: "Too many password attempts" })
|
||||
async setPassword(@Body() setPasswordDto: SetPasswordDto, @Req() req: Request) {
|
||||
return this.authService.setPassword(setPasswordDto, req);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("check-password-needed")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Check if user needs to set password" })
|
||||
@ApiResponse({ status: 200, description: "Password status checked" })
|
||||
async checkPasswordNeeded(@Body() { email }: { email: string }) {
|
||||
return this.authService.checkPasswordNeeded(email);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("request-password-reset")
|
||||
@Throttle({ default: { limit: 5, ttl: 900000 } })
|
||||
@ApiOperation({ summary: "Request password reset email" })
|
||||
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
|
||||
async requestPasswordReset(@Body() body: RequestPasswordResetDto) {
|
||||
await this.authService.requestPasswordReset(body.email);
|
||||
return { message: "If an account exists, a reset email has been sent" };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("reset-password")
|
||||
@Throttle({ default: { limit: 5, ttl: 900000 } })
|
||||
@ApiOperation({ summary: "Reset password with token" })
|
||||
@ApiResponse({ status: 200, description: "Password reset successful" })
|
||||
async resetPassword(@Body() body: ResetPasswordDto) {
|
||||
return this.authService.resetPassword(body.token, body.password);
|
||||
}
|
||||
|
||||
@Post("change-password")
|
||||
@Throttle({ default: { limit: 5, ttl: 300000 } })
|
||||
@ApiOperation({ summary: "Change password (authenticated)" })
|
||||
@ApiResponse({ status: 200, description: "Password changed successfully" })
|
||||
async changePassword(
|
||||
@Req() req: Request & { user: { id: string } },
|
||||
@Body() body: ChangePasswordDto
|
||||
) {
|
||||
return this.authService.changePassword(req.user.id, body.currentPassword, body.newPassword);
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@ApiOperation({ summary: "Get current authentication status" })
|
||||
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
|
||||
// Return basic auth info only - full profile should use /api/me
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
id: req.user.id,
|
||||
email: req.user.email,
|
||||
role: req.user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post("sso-link")
|
||||
@ApiOperation({ summary: "Create SSO link to WHMCS" })
|
||||
@ApiResponse({ status: 200, description: "SSO link created successfully" })
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: "User not found or not linked to WHMCS",
|
||||
})
|
||||
async createSsoLink(@Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkDto) {
|
||||
const destination = body?.destination;
|
||||
return this.authService.createSsoLink(req.user.id, destination);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Routes } from "@nestjs/core";
|
||||
import { AuthModule } from "../../auth/auth.module";
|
||||
import { UsersModule } from "../../users/users.module";
|
||||
import { MappingsModule } from "../../id-mappings/mappings.module";
|
||||
import { CatalogModule } from "../../catalog/catalog.module";
|
||||
import { OrdersModule } from "../../orders/orders.module";
|
||||
import { InvoicesModule } from "../../invoices/invoices.module";
|
||||
import { SubscriptionsModule } from "../../subscriptions/subscriptions.module";
|
||||
import { CasesModule } from "../../cases/cases.module";
|
||||
import { AuthModule } from "@bff/modules/auth/auth.module";
|
||||
import { UsersModule } from "@bff/modules/users/users.module";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module";
|
||||
import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
|
||||
import { CasesModule } from "@bff/modules/cases/cases.module";
|
||||
|
||||
export const apiRoutes: Routes = [
|
||||
{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
||||
import { OrdersModule } from "../../../orders/orders.module";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module";
|
||||
import { SalesforcePubSubSubscriber } from "./pubsub.subscriber";
|
||||
|
||||
@Module({
|
||||
|
||||
@ -3,7 +3,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||
import { SalesforceConnection } from "../services/salesforce-connection.service";
|
||||
import { ProvisioningQueueService } from "../../../orders/queue/provisioning.queue";
|
||||
import { ProvisioningQueueService } from "@bff/modules/orders/queue/provisioning.queue";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service";
|
||||
import {
|
||||
replayKey as sfReplayKey,
|
||||
|
||||
@ -6,7 +6,7 @@ import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
|
||||
import { Public } from "./decorators/public.decorator";
|
||||
import { ZodPipe } from "../core/validation";
|
||||
import { ZodPipe } from "@bff/core/validation";
|
||||
|
||||
// Import Zod schemas from domain
|
||||
import {
|
||||
@ -4,10 +4,9 @@ import { PassportModule } from "@nestjs/passport";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthZodController } from "./auth-zod.controller";
|
||||
import { AuthAdminController } from "./auth-admin.controller";
|
||||
import { UsersModule } from "@/users/users.module";
|
||||
import { UsersModule } from "@bff/modules/users/users.module";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
||||
import { JwtStrategy } from "./strategies/jwt.strategy";
|
||||
@ -281,7 +281,7 @@ export class AuthService {
|
||||
|
||||
// Validate required WHMCS fields
|
||||
if (
|
||||
!address?.line1 ||
|
||||
!address?.street ||
|
||||
!address?.city ||
|
||||
!address?.state ||
|
||||
!address?.postalCode ||
|
||||
@ -311,8 +311,8 @@ export class AuthService {
|
||||
email,
|
||||
companyname: company || "",
|
||||
phonenumber: phone,
|
||||
address1: address.line1,
|
||||
address2: address.line2 || "",
|
||||
address1: address.street,
|
||||
address2: address.streetLine2 || "",
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postalCode,
|
||||
@ -15,12 +15,12 @@ export class AddressDto {
|
||||
@ApiProperty({ example: "123 Main Street" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
line1: string;
|
||||
street: string;
|
||||
|
||||
@ApiProperty({ example: "Apt 4B", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
line2?: string;
|
||||
streetLine2?: string;
|
||||
|
||||
@ApiProperty({ example: "Tokyo" })
|
||||
@IsString()
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
|
||||
import { MappingsService } from "../../id-mappings/mappings.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
|
||||
import { MappingsService } from "../../id-mappings/mappings.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "../../infra/cache/cache.service";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service";
|
||||
import { UserIdMapping } from "../types/mapping.types";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CreateOrderBody, UserMapping } from "../dto/order.dto";
|
||||
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
||||
import { UsersService } from "../../users/users.service";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
|
||||
/**
|
||||
* Handles building order header data from selections
|
||||
@ -2,7 +2,7 @@ import { Injectable, BadRequestException, ConflictException, Inject } from "@nes
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service";
|
||||
import { MappingsService } from "../../id-mappings/mappings.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { SalesforceOrder } from "@customer-portal/domain";
|
||||
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
||||
@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { MappingsService } from "../../id-mappings/mappings.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
||||
@ -9,7 +9,7 @@
|
||||
"@/*": ["src/*"],
|
||||
"@bff/core/*": ["src/core/*"],
|
||||
"@bff/infra/*": ["src/infra/*"],
|
||||
"@bff/modules/*": ["src/*"],
|
||||
"@bff/modules/*": ["src/modules/*"],
|
||||
"@bff/integrations/*": ["src/integrations/*"]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"@/*": ["src/*"],
|
||||
"@bff/core/*": ["src/core/*"],
|
||||
"@bff/infra/*": ["src/infra/*"],
|
||||
"@bff/modules/*": ["src/*"],
|
||||
"@bff/modules/*": ["src/modules/*"],
|
||||
"@bff/integrations/*": ["src/integrations/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
// Memory optimization settings
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo-memory",
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
// Reduce type checking strictness for memory optimization
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
"strictBindCallApply": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
// Disable some expensive checks
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noImplicitOverride": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"test",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.e2e-spec.ts"
|
||||
]
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@bff/core/*": ["src/core/*"],
|
||||
"@bff/infra/*": ["src/infra/*"],
|
||||
"@bff/modules/*": ["src/*"],
|
||||
"@bff/integrations/*": ["src/integrations/*"]
|
||||
},
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"types": ["node"],
|
||||
|
||||
// Ultra-light settings - disable most type checking for memory
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
"strictBindCallApply": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noImplicitOverride": false,
|
||||
"strictPropertyInitialization": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noImplicitThis": false,
|
||||
"alwaysStrict": false,
|
||||
"strictFunctionTypes": false,
|
||||
"useUnknownInCatchVariables": false,
|
||||
|
||||
// Performance optimizations
|
||||
"incremental": false,
|
||||
"assumeChangesOnlyAffectDirectDependencies": true,
|
||||
"disableReferencedProjectLoad": true,
|
||||
"disableSolutionSearching": true,
|
||||
"disableSourceOfProjectReferenceRedirect": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"test",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.e2e-spec.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
@ -7,7 +7,7 @@ const nextConfig = {
|
||||
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
|
||||
|
||||
// Ensure workspace package resolves/transpiles correctly in monorepo
|
||||
transpilePackages: ["@customer-portal/shared"],
|
||||
transpilePackages: [],
|
||||
experimental: {
|
||||
externalDir: true,
|
||||
},
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
"test": "echo 'No tests yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@customer-portal/shared": "workspace:*",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
|
||||
@ -1,431 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
MapPinIcon,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
interface Address {
|
||||
street: string | null;
|
||||
streetLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
interface BillingInfo {
|
||||
company: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
address: Address;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
function BillingHeading() {
|
||||
const searchParams = useSearchParams();
|
||||
const isCompletionFlow = searchParams.get("complete") === "true";
|
||||
return (
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{isCompletionFlow ? "Complete Your Profile" : "Billing & Address"}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingCompletionBanner() {
|
||||
const searchParams = useSearchParams();
|
||||
const isCompletionFlow = searchParams.get("complete") === "true";
|
||||
if (!isCompletionFlow) return null;
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Profile Completion Required</h3>
|
||||
<p className="text-blue-800">
|
||||
Please review and complete your address information to access all features and enable
|
||||
service ordering.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBillingInfo();
|
||||
}, []);
|
||||
|
||||
const fetchBillingInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
|
||||
setBillingInfo(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load billing information");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditing(true);
|
||||
setEditedAddress(
|
||||
billingInfo?.address || {
|
||||
street: "",
|
||||
streetLine2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editedAddress) return;
|
||||
|
||||
// Validate required fields
|
||||
const isComplete = !!(
|
||||
editedAddress.street?.trim() &&
|
||||
editedAddress.city?.trim() &&
|
||||
editedAddress.state?.trim() &&
|
||||
editedAddress.postalCode?.trim() &&
|
||||
editedAddress.country?.trim()
|
||||
);
|
||||
|
||||
if (!isComplete) {
|
||||
setError("Please fill in all required address fields");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Update address via API
|
||||
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
|
||||
street: editedAddress.street,
|
||||
streetLine2: editedAddress.streetLine2,
|
||||
city: editedAddress.city,
|
||||
state: editedAddress.state,
|
||||
postalCode: editedAddress.postalCode,
|
||||
country: editedAddress.country,
|
||||
});
|
||||
|
||||
// Update local state from authoritative response
|
||||
setBillingInfo(updated);
|
||||
|
||||
setEditing(false);
|
||||
setEditedAddress(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update address");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setEditedAddress(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="text-xl font-semibold text-gray-900">Loading billing information...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<CreditCardIcon className="h-8 w-8 text-blue-600" />
|
||||
<Suspense
|
||||
fallback={<h1 className="text-3xl font-bold text-gray-900">Billing & Address</h1>}
|
||||
>
|
||||
<BillingHeading />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<BillingCompletionBanner />
|
||||
</Suspense>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Address Information */}
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MapPinIcon className="h-6 w-6 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Service Address</h2>
|
||||
</div>
|
||||
{billingInfo?.isComplete && !editing && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address is required at signup, so this should rarely be needed */}
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Street Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.street || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, street: e.target.value } : null))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="123 Main Street"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Street Address Line 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.streetLine2 || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev =>
|
||||
prev ? { ...prev, streetLine2: e.target.value } : null
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.city || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Tokyo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
State/Prefecture *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.state || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Tokyo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Postal Code *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.postalCode || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev =>
|
||||
prev ? { ...prev, postalCode: e.target.value } : null
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="100-0001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Country *
|
||||
</label>
|
||||
<select
|
||||
value={editedAddress?.country || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev =>
|
||||
prev ? { ...prev, country: e.target.value } : null
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Country</option>
|
||||
<option value="JP">Japan</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
<span>{saving ? "Saving..." : "Save Address"}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 disabled:bg-gray-300 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{billingInfo?.address.street ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-medium">{billingInfo.address.street}</p>
|
||||
{billingInfo.address.streetLine2 && <p>{billingInfo.address.streetLine2}</p>}
|
||||
<p>
|
||||
{billingInfo.address.city}, {billingInfo.address.state}{" "}
|
||||
{billingInfo.address.postalCode}
|
||||
</p>
|
||||
<p>{billingInfo.address.country}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
{billingInfo.isComplete ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-700 font-medium">
|
||||
Address Complete
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-700 font-medium">
|
||||
Address Incomplete
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No address on file</p>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Address
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<CreditCardIcon className="h-6 w-6 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Contact Information</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-900">{billingInfo?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{billingInfo?.company && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Company</label>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-900">{billingInfo.company}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{billingInfo?.phone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-gray-900">{billingInfo.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Note:</strong> Contact information is managed through your account settings.
|
||||
Address changes are synchronized with our billing system. This address is used for
|
||||
both billing and service delivery.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/lib/auth/store";
|
||||
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { useDashboardSummary } from "@/features/dashboard/hooks/useDashboard";
|
||||
|
||||
import type { Activity } from "@customer-portal/shared";
|
||||
import type { Activity } from "@customer-portal/domain";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ServerIcon,
|
||||
@ -31,11 +29,11 @@ import { format, formatDistanceToNow } from "date-fns";
|
||||
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-skeleton";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const { user, isAuthenticated, loading: authLoading } = useAuthStore();
|
||||
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
||||
|
||||
const [paymentLoading, setPaymentLoading] = useState(false);
|
||||
@ -48,11 +46,11 @@ export default function DashboardPage() {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { createInvoiceSsoLink } = await import("@/hooks/useInvoices");
|
||||
const ssoLink = await createInvoiceSsoLink(invoiceId, "pay");
|
||||
const { BillingService } = await import("@/features/billing/services");
|
||||
const ssoLink = await BillingService.createInvoiceSsoLink({ invoiceId, target: "pay" });
|
||||
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to create payment link");
|
||||
console.error("Failed to create payment link:", error);
|
||||
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
|
||||
} finally {
|
||||
setPaymentLoading(false);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logger } from "@/core/config";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/providers/query-provider";
|
||||
import { SessionTimeoutWarning } from "@/components/auth/session-timeout-warning";
|
||||
import { QueryProvider } from "@/core/providers";
|
||||
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function AuthLayout({ children, title, subtitle }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">AS</span>
|
||||
</div>
|
||||
<span className="text-xl font-semibold text-gray-900">Assist Solutions</span>
|
||||
</div>
|
||||
</Link>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">{title}</h2>
|
||||
{subtitle && <p className="mt-2 text-sm text-gray-600">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500">© 2025 Assist Solutions. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuthStore } from "@/lib/auth/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface SessionTimeoutWarningProps {
|
||||
warningTime?: number; // Minutes before token expires to show warning
|
||||
}
|
||||
|
||||
export function SessionTimeoutWarning({
|
||||
warningTime = 10, // Show warning 10 minutes before expiry
|
||||
}: SessionTimeoutWarningProps) {
|
||||
const { isAuthenticated, token, logout, checkAuth } = useAuthStore();
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse JWT to get expiry time
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1])) as { exp?: number };
|
||||
if (!payload.exp) {
|
||||
logger.warn("Token does not have expiration time");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
||||
const currentTime = Date.now();
|
||||
const warningThreshold = warningTime * 60 * 1000; // Convert to milliseconds
|
||||
|
||||
const timeUntilExpiry = expiryTime - currentTime;
|
||||
const timeUntilWarning = timeUntilExpiry - warningThreshold;
|
||||
|
||||
if (timeUntilExpiry <= 0) {
|
||||
// Token already expired
|
||||
void logout();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (timeUntilWarning <= 0) {
|
||||
// Should show warning immediately
|
||||
setShowWarning(true);
|
||||
setTimeLeft(Math.ceil(timeUntilExpiry / 1000 / 60)); // Minutes left
|
||||
return undefined;
|
||||
} else {
|
||||
// Set timeout to show warning
|
||||
const warningTimeout = setTimeout(() => {
|
||||
setShowWarning(true);
|
||||
setTimeLeft(warningTime);
|
||||
}, timeUntilWarning);
|
||||
|
||||
return () => clearTimeout(warningTimeout);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error parsing JWT token");
|
||||
void logout();
|
||||
return undefined;
|
||||
}
|
||||
}, [isAuthenticated, token, warningTime, logout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWarning) return undefined;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
void logout();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [showWarning, logout]);
|
||||
|
||||
const handleExtendSession = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await checkAuth();
|
||||
setShowWarning(false);
|
||||
setTimeLeft(0);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to extend session");
|
||||
await logout();
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleLogoutNow = () => {
|
||||
void logout();
|
||||
setShowWarning(false);
|
||||
};
|
||||
|
||||
if (!showWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-yellow-500 text-xl">⚠️</span>
|
||||
<h2 className="text-lg font-semibold">Session Expiring Soon</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your session will expire in{" "}
|
||||
<strong>
|
||||
{timeLeft} minute{timeLeft !== 1 ? "s" : ""}
|
||||
</strong>
|
||||
. Would you like to extend your session?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={handleLogoutNow}>
|
||||
Logout Now
|
||||
</Button>
|
||||
<Button onClick={handleExtendSession}>Extend Session</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
interface ActivationFormProps {
|
||||
activationType: "Immediate" | "Scheduled";
|
||||
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
|
||||
scheduledActivationDate: string;
|
||||
onScheduledActivationDateChange: (date: string) => void;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export function ActivationForm({
|
||||
activationType,
|
||||
onActivationTypeChange,
|
||||
scheduledActivationDate,
|
||||
onScheduledActivationDateChange,
|
||||
errors,
|
||||
}: ActivationFormProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label
|
||||
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.01] ${
|
||||
activationType === "Immediate"
|
||||
? "border-blue-500 bg-blue-50 ring-2 ring-blue-100 shadow-sm"
|
||||
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="activationType"
|
||||
value="Immediate"
|
||||
checked={activationType === "Immediate"}
|
||||
onChange={e => onActivationTypeChange(e.target.value as "Immediate")}
|
||||
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">Immediate Activation</span>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Activate your SIM card as soon as it's delivered and set up
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.01] ${
|
||||
activationType === "Scheduled"
|
||||
? "border-blue-500 bg-blue-50 ring-2 ring-blue-100 shadow-sm"
|
||||
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="activationType"
|
||||
value="Scheduled"
|
||||
checked={activationType === "Scheduled"}
|
||||
onChange={e => onActivationTypeChange(e.target.value as "Scheduled")}
|
||||
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium text-gray-900">Scheduled Activation</span>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Choose a specific date for activation (up to 30 days from today)
|
||||
</p>
|
||||
|
||||
{activationType === "Scheduled" && (
|
||||
<div className="mt-3">
|
||||
<label
|
||||
htmlFor="scheduledActivationDate"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Preferred Activation Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="scheduledActivationDate"
|
||||
value={scheduledActivationDate}
|
||||
onChange={e => onScheduledActivationDateChange(e.target.value)}
|
||||
min={new Date().toISOString().split("T")[0]} // Today's date
|
||||
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]} // 30 days from now
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{errors.scheduledActivationDate && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors.scheduledActivationDate}</p>
|
||||
)}
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Note: Scheduled activation is subject to business day processing. Weekend/holiday
|
||||
requests may be processed on the next business day.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface AddonItem {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
isBundledAddon?: boolean;
|
||||
bundledAddonId?: string;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
interface AddonGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
skus: string[];
|
||||
isBundled: boolean;
|
||||
}
|
||||
|
||||
interface AddonGroupProps {
|
||||
addons: AddonItem[];
|
||||
selectedAddonSkus: string[];
|
||||
onAddonToggle: (skus: string[]) => void;
|
||||
showSkus?: boolean;
|
||||
}
|
||||
|
||||
export function AddonGroup({
|
||||
addons,
|
||||
selectedAddonSkus,
|
||||
onAddonToggle,
|
||||
showSkus = false,
|
||||
}: AddonGroupProps) {
|
||||
// Group bundled addons together
|
||||
const groupedAddons = (() => {
|
||||
const groups: AddonGroup[] = [];
|
||||
const processedAddonIds = new Set<string>();
|
||||
|
||||
// Sort addons by display order first
|
||||
const sortedAddons = [...addons].sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
|
||||
|
||||
sortedAddons.forEach(addon => {
|
||||
if (processedAddonIds.has(addon.id)) return;
|
||||
|
||||
if (addon.isBundledAddon && addon.bundledAddonId) {
|
||||
// Find the bundled partner
|
||||
const bundledPartner = sortedAddons.find(a => a.id === addon.bundledAddonId);
|
||||
|
||||
if (bundledPartner && !processedAddonIds.has(bundledPartner.id)) {
|
||||
// Create a combined group
|
||||
const monthlyAddon = addon.monthlyPrice ? addon : bundledPartner;
|
||||
const activationAddon = addon.oneTimePrice ? addon : bundledPartner;
|
||||
|
||||
// Generate clean name and description
|
||||
const cleanName = monthlyAddon.name
|
||||
.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "")
|
||||
.trim();
|
||||
const bundleName = cleanName || monthlyAddon.name.split(" ").slice(0, 2).join(" "); // Use first two words if cleaning removes everything
|
||||
|
||||
groups.push({
|
||||
id: `bundle-${addon.id}-${bundledPartner.id}`,
|
||||
name: bundleName,
|
||||
description: `${bundleName} (installation included)`,
|
||||
monthlyPrice: monthlyAddon.monthlyPrice,
|
||||
oneTimePrice: activationAddon.oneTimePrice,
|
||||
skus: [addon.sku, bundledPartner.sku],
|
||||
isBundled: true,
|
||||
});
|
||||
|
||||
processedAddonIds.add(addon.id);
|
||||
processedAddonIds.add(bundledPartner.id);
|
||||
} else if (!bundledPartner) {
|
||||
// Orphaned bundled addon - treat as individual
|
||||
groups.push({
|
||||
id: addon.id,
|
||||
name: addon.name,
|
||||
description: addon.description,
|
||||
monthlyPrice: addon.monthlyPrice,
|
||||
oneTimePrice: addon.oneTimePrice,
|
||||
skus: [addon.sku],
|
||||
isBundled: false,
|
||||
});
|
||||
processedAddonIds.add(addon.id);
|
||||
}
|
||||
} else {
|
||||
// Individual addon
|
||||
groups.push({
|
||||
id: addon.id,
|
||||
name: addon.name,
|
||||
description: addon.description,
|
||||
monthlyPrice: addon.monthlyPrice,
|
||||
oneTimePrice: addon.oneTimePrice,
|
||||
skus: [addon.sku],
|
||||
isBundled: false,
|
||||
});
|
||||
processedAddonIds.add(addon.id);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
})();
|
||||
|
||||
const handleGroupToggle = (addonGroup: AddonGroup) => {
|
||||
const allSkusSelected = addonGroup.skus.every(sku => selectedAddonSkus.includes(sku));
|
||||
|
||||
if (allSkusSelected) {
|
||||
// Unselect all SKUs in the bundle
|
||||
const remainingSkus = selectedAddonSkus.filter(sku => !addonGroup.skus.includes(sku));
|
||||
onAddonToggle(remainingSkus);
|
||||
} else {
|
||||
// Select all SKUs in the bundle
|
||||
const filtered = selectedAddonSkus.filter(sku => !addonGroup.skus.includes(sku));
|
||||
onAddonToggle([...filtered, ...addonGroup.skus]);
|
||||
}
|
||||
};
|
||||
|
||||
if (groupedAddons.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p>No add-ons available for this plan</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groupedAddons.map(addonGroup => {
|
||||
const allSkusSelected = addonGroup.skus.every(sku => selectedAddonSkus.includes(sku));
|
||||
|
||||
return (
|
||||
<label
|
||||
key={addonGroup.id}
|
||||
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
allSkusSelected
|
||||
? "border-green-500 bg-green-50 ring-2 ring-green-100"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSkusSelected}
|
||||
onChange={() => handleGroupToggle(addonGroup)}
|
||||
className="text-green-600 focus:ring-green-500 mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{addonGroup.name}</span>
|
||||
{allSkusSelected && <CheckCircleIcon className="h-5 w-5 text-green-600" />}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{addonGroup.description}</p>
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{addonGroup.monthlyPrice && (
|
||||
<span className="text-sm font-semibold text-blue-600">
|
||||
¥{addonGroup.monthlyPrice.toLocaleString()}/month
|
||||
</span>
|
||||
)}
|
||||
{addonGroup.oneTimePrice && (
|
||||
<span className="text-sm font-semibold text-orange-600">
|
||||
Activation: ¥{addonGroup.oneTimePrice.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{addonGroup.isBundled && (
|
||||
<div className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Bundle Package
|
||||
</div>
|
||||
)}
|
||||
{showSkus && (
|
||||
<div className="text-xs text-gray-500 mt-1">SKUs: {addonGroup.skus.join(", ")}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedAddonSkus.length === 0 && (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p>Select add-ons to enhance your service</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface AnimatedButtonProps {
|
||||
children: ReactNode;
|
||||
variant?: "primary" | "secondary" | "outline";
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit";
|
||||
}
|
||||
|
||||
export function AnimatedButton({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
onClick,
|
||||
href,
|
||||
disabled = false,
|
||||
type = "button",
|
||||
}: AnimatedButtonProps) {
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-300 ease-in-out transform focus:outline-none focus:ring-2 focus:ring-offset-2";
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-3 py-2 text-sm",
|
||||
md: "px-6 py-3 text-base",
|
||||
lg: "px-8 py-4 text-lg",
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 hover:scale-105 hover:shadow-lg",
|
||||
secondary:
|
||||
"bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 hover:scale-105 hover:shadow-lg",
|
||||
outline:
|
||||
"border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500 hover:scale-105",
|
||||
};
|
||||
|
||||
const disabledClasses = disabled
|
||||
? "opacity-50 cursor-not-allowed transform-none hover:scale-100 hover:shadow-none"
|
||||
: "";
|
||||
|
||||
const allClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${disabledClasses} ${className}`;
|
||||
|
||||
if (href && !disabled) {
|
||||
return (
|
||||
<Link href={href} className={allClasses}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={allClasses}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AnimatedCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: "default" | "highlighted" | "success" | "static";
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AnimatedCard({
|
||||
children,
|
||||
className = "",
|
||||
variant = "default",
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: AnimatedCardProps) {
|
||||
const baseClasses =
|
||||
"bg-white rounded-xl border-2 shadow-sm transition-all duration-300 ease-in-out transform";
|
||||
|
||||
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
|
||||
default: "border-gray-200 hover:shadow-xl hover:-translate-y-1",
|
||||
highlighted:
|
||||
"border-blue-300 ring-2 ring-blue-100 shadow-md hover:shadow-xl hover:-translate-y-1",
|
||||
success:
|
||||
"border-green-300 ring-2 ring-green-100 shadow-md hover:shadow-xl hover:-translate-y-1",
|
||||
static: "border-gray-200 shadow-sm", // No hover animations for static containers
|
||||
};
|
||||
|
||||
const interactiveClasses = onClick && !disabled ? "cursor-pointer hover:scale-[1.02]" : "";
|
||||
|
||||
const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user