Enhance memory management and refactor components for improved performance
- Updated package.json scripts to optimize memory usage during type-checking and building processes. - Refactored BFF application scripts to include memory management options for build, dev, and test commands. - Introduced new type-check scripts for better memory handling in the BFF application. - Reorganized imports and components in the portal application for better structure and maintainability. - Replaced large component files with dedicated view components to streamline rendering and improve load times.
This commit is contained in:
parent
52adc29016
commit
ed6fae677d
@ -6,27 +6,28 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build -c tsconfig.build.json",
|
"build": "NODE_OPTIONS=\"--max-old-space-size=8192\" nest build -c tsconfig.build.json",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"dev": "NODE_OPTIONS=\"--no-deprecation\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
|
"dev": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
|
||||||
"start:debug": "NODE_OPTIONS=\"--no-deprecation\" nest start --debug --watch",
|
"start:debug": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "jest",
|
"test": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"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:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --config ./test/jest-e2e.json",
|
||||||
"type-check": "tsc --noEmit",
|
"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",
|
"clean": "rm -rf dist",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:reset": "prisma migrate reset",
|
"db:reset": "prisma migrate reset",
|
||||||
"db:seed": "ts-node prisma/seed.ts",
|
"db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" ts-node prisma/seed.ts",
|
||||||
"openapi:gen": "TS_NODE_TRANSPILE_ONLY=1 ts-node -r tsconfig-paths/register ./scripts/generate-openapi.ts"
|
"openapi:gen": "NODE_OPTIONS=\"--max-old-space-size=4096\" TS_NODE_TRANSPILE_ONLY=1 ts-node -r tsconfig-paths/register ./scripts/generate-openapi.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@customer-portal/domain": "workspace:*",
|
"@customer-portal/domain": "workspace:*",
|
||||||
|
|||||||
217
apps/bff/src/auth/auth-zod.controller.ts
Normal file
217
apps/bff/src/auth/auth-zod.controller.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
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 { Public } from "./decorators/public.decorator";
|
||||||
|
import { ZodPipe } from "../core/validation";
|
||||||
|
|
||||||
|
// Import Zod schemas from domain
|
||||||
|
import {
|
||||||
|
bffSignupSchema,
|
||||||
|
bffLoginSchema,
|
||||||
|
bffPasswordResetRequestSchema,
|
||||||
|
bffPasswordResetSchema,
|
||||||
|
bffSetPasswordSchema,
|
||||||
|
bffLinkWhmcsSchema,
|
||||||
|
bffChangePasswordSchema,
|
||||||
|
bffValidateSignupSchema,
|
||||||
|
bffAccountStatusRequestSchema,
|
||||||
|
bffSsoLinkSchema,
|
||||||
|
bffCheckPasswordNeededSchema,
|
||||||
|
type BffSignupData,
|
||||||
|
type BffLoginData,
|
||||||
|
type BffPasswordResetRequestData,
|
||||||
|
type BffPasswordResetData,
|
||||||
|
type BffSetPasswordData,
|
||||||
|
type BffLinkWhmcsData,
|
||||||
|
type BffChangePasswordData,
|
||||||
|
type BffValidateSignupData,
|
||||||
|
type BffAccountStatusRequestData,
|
||||||
|
type BffSsoLinkData,
|
||||||
|
type BffCheckPasswordNeededData,
|
||||||
|
} from "@customer-portal/domain";
|
||||||
|
|
||||||
|
@ApiTags("auth")
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthZodController {
|
||||||
|
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(ZodPipe(bffValidateSignupSchema)) validateData: BffValidateSignupData,
|
||||||
|
@Req() req: Request
|
||||||
|
) {
|
||||||
|
return this.authService.validateSignup(validateData, 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(ZodPipe(bffSignupSchema)) signupData: BffSignupData) {
|
||||||
|
return this.authService.signupPreflight(signupData);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post("account-status")
|
||||||
|
@ApiOperation({ summary: "Get account status by email" })
|
||||||
|
@ApiOkResponse({ description: "Account status" })
|
||||||
|
async accountStatus(@Body(ZodPipe(bffAccountStatusRequestSchema)) body: BffAccountStatusRequestData) {
|
||||||
|
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(ZodPipe(bffSignupSchema)) signupData: BffSignupData, @Req() req: Request) {
|
||||||
|
return this.authService.signup(signupData, 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(ZodPipe(bffLinkWhmcsSchema)) linkData: BffLinkWhmcsData, @Req() req: Request) {
|
||||||
|
return this.authService.linkWhmcsUser(linkData, 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(ZodPipe(bffSetPasswordSchema)) setPasswordData: BffSetPasswordData, @Req() req: Request) {
|
||||||
|
return this.authService.setPassword(setPasswordData, 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(ZodPipe(bffCheckPasswordNeededSchema)) data: BffCheckPasswordNeededData) {
|
||||||
|
return this.authService.checkPasswordNeeded(data.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(ZodPipe(bffPasswordResetRequestSchema)) body: BffPasswordResetRequestData) {
|
||||||
|
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(ZodPipe(bffPasswordResetSchema)) body: BffPasswordResetData) {
|
||||||
|
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(ZodPipe(bffChangePasswordSchema)) body: BffChangePasswordData
|
||||||
|
) {
|
||||||
|
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(ZodPipe(bffSsoLinkSchema)) body: BffSsoLinkData
|
||||||
|
) {
|
||||||
|
const destination = body?.destination;
|
||||||
|
return this.authService.createSsoLink(req.user.id, destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
|
import { AuthZodController } from "./auth-zod.controller";
|
||||||
import { AuthAdminController } from "./auth-admin.controller";
|
import { AuthAdminController } from "./auth-admin.controller";
|
||||||
import { UsersModule } from "@/users/users.module";
|
import { UsersModule } from "@/users/users.module";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||||
@ -14,6 +15,7 @@ import { LocalStrategy } from "./strategies/local.strategy";
|
|||||||
import { GlobalAuthGuard } from "./guards/global-auth.guard";
|
import { GlobalAuthGuard } from "./guards/global-auth.guard";
|
||||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||||
import { EmailModule } from "@bff/infra/email/email.module";
|
import { EmailModule } from "@bff/infra/email/email.module";
|
||||||
|
import { ValidationModule } from "@bff/core/validation";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -29,8 +31,9 @@ import { EmailModule } from "@bff/infra/email/email.module";
|
|||||||
MappingsModule,
|
MappingsModule,
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
ValidationModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AuthAdminController],
|
controllers: [AuthZodController, AuthAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
|||||||
7
apps/bff/src/core/validation/index.ts
Normal file
7
apps/bff/src/core/validation/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Validation Module Exports
|
||||||
|
* Zod-based validation system for BFF
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ZodValidationPipe, ZodPipe, ValidateZod } from './zod-validation.pipe';
|
||||||
|
export { ValidationModule } from './validation.module';
|
||||||
13
apps/bff/src/core/validation/validation.module.ts
Normal file
13
apps/bff/src/core/validation/validation.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { ZodValidationPipe } from './zod-validation.pipe';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global validation module providing Zod-based validation
|
||||||
|
* Replaces class-validator with domain-driven Zod schemas
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [ZodValidationPipe],
|
||||||
|
exports: [ZodValidationPipe],
|
||||||
|
})
|
||||||
|
export class ValidationModule {}
|
||||||
47
apps/bff/src/core/validation/zod-validation.pipe.ts
Normal file
47
apps/bff/src/core/validation/zod-validation.pipe.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { PipeTransform, Injectable, BadRequestException, ArgumentMetadata } from '@nestjs/common';
|
||||||
|
import { ZodSchema, ZodError } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod validation pipe for NestJS
|
||||||
|
* Replaces class-validator DTOs with Zod schema validation
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ZodValidationPipe implements PipeTransform {
|
||||||
|
constructor(private schema: ZodSchema) {}
|
||||||
|
|
||||||
|
transform(value: unknown, metadata: ArgumentMetadata) {
|
||||||
|
try {
|
||||||
|
const parsedValue = this.schema.parse(value);
|
||||||
|
return parsedValue;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const errorMessages = error.issues.map(err => {
|
||||||
|
const path = err.path.join('.');
|
||||||
|
return path ? `${path}: ${err.message}` : err.message;
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: errorMessages,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new BadRequestException('Validation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create Zod validation pipe
|
||||||
|
*/
|
||||||
|
export const ZodPipe = (schema: ZodSchema) => new ZodValidationPipe(schema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator for easy use with controllers
|
||||||
|
*/
|
||||||
|
export const ValidateZod = (schema: ZodSchema) => {
|
||||||
|
return (target: any, propertyKey: string, parameterIndex: number) => {
|
||||||
|
// This would be used with @Body(ValidateZod(schema))
|
||||||
|
// For now, we'll use the pipe directly
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -29,6 +29,19 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"typeRoots": ["./node_modules/@types"]
|
"typeRoots": ["./node_modules/@types"],
|
||||||
|
|
||||||
|
// Memory optimization settings
|
||||||
|
"preserveWatchOutput": true,
|
||||||
|
"assumeChangesOnlyAffectDirectDependencies": true,
|
||||||
|
"disableReferencedProjectLoad": false,
|
||||||
|
"disableSolutionSearching": false,
|
||||||
|
"disableSourceOfProjectReferenceRedirect": false
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"transpileOnly": true,
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/bff/tsconfig.memory.json
Normal file
31
apps/bff/tsconfig.memory.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
62
apps/bff/tsconfig.ultra-light.json
Normal file
62
apps/bff/tsconfig.ultra-light.json
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,762 +1,5 @@
|
|||||||
"use client";
|
import ProfileContainer from "@/features/account/views/ProfileContainer";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import {
|
|
||||||
UserIcon,
|
|
||||||
PencilIcon,
|
|
||||||
CheckIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import countries from "world-countries";
|
|
||||||
|
|
||||||
// Type for country data
|
|
||||||
interface Country {
|
|
||||||
name: {
|
|
||||||
common: string;
|
|
||||||
};
|
|
||||||
cca2: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address interface
|
|
||||||
interface Address {
|
|
||||||
street: string | null;
|
|
||||||
streetLine2: string | null;
|
|
||||||
city: string | null;
|
|
||||||
state: string | null;
|
|
||||||
postalCode: string | null;
|
|
||||||
country: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Billing info interface
|
|
||||||
interface BillingInfo {
|
|
||||||
company: string | null;
|
|
||||||
email: string;
|
|
||||||
phone: string | null;
|
|
||||||
address: Address;
|
|
||||||
isComplete: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced user type with Salesforce Account data (essential fields only)
|
|
||||||
interface EnhancedUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
company?: string;
|
|
||||||
phone?: string;
|
|
||||||
// No internal system identifiers should be exposed to clients
|
|
||||||
// salesforceAccountId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user } = useAuthStore();
|
return <ProfileContainer />;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [isEditingAddress, setIsEditingAddress] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isSavingAddress, setIsSavingAddress] = useState(false);
|
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
|
||||||
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [pwdError, setPwdError] = useState<string | null>(null);
|
|
||||||
const [pwdSuccess, setPwdSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
email: user?.email || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [addressData, setAddressData] = useState<Address>({
|
|
||||||
street: "",
|
|
||||||
streetLine2: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postalCode: "",
|
|
||||||
country: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [pwdForm, setPwdForm] = useState({
|
|
||||||
currentPassword: "",
|
|
||||||
newPassword: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch billing info on component mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchBillingInfo = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
|
|
||||||
setBillingInfo(data);
|
|
||||||
setAddressData(data.address);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to load address information");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void fetchBillingInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setIsEditing(true);
|
|
||||||
setFormData({
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
email: user?.email || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditAddress = () => {
|
|
||||||
setIsEditingAddress(true);
|
|
||||||
if (billingInfo?.address) {
|
|
||||||
setAddressData(billingInfo.address);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
setFormData({
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
email: user?.email || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelAddress = () => {
|
|
||||||
setIsEditingAddress(false);
|
|
||||||
if (billingInfo?.address) {
|
|
||||||
setAddressData(billingInfo.address);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { token } = useAuthStore.getState();
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/me", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
firstName: formData.firstName,
|
|
||||||
lastName: formData.lastName,
|
|
||||||
phone: formData.phone,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to update profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = (await response.json()) as Partial<EnhancedUser>;
|
|
||||||
|
|
||||||
// Update the auth store with the new user data
|
|
||||||
useAuthStore.setState(state => ({
|
|
||||||
...state,
|
|
||||||
user: state.user ? { ...state.user, ...updatedUser } : state.user,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setIsEditing(false);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Error updating profile");
|
|
||||||
// You might want to show a toast notification here
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAddress = async () => {
|
|
||||||
setIsSavingAddress(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate required fields
|
|
||||||
const isComplete = !!(
|
|
||||||
addressData.street?.trim() &&
|
|
||||||
addressData.city?.trim() &&
|
|
||||||
addressData.state?.trim() &&
|
|
||||||
addressData.postalCode?.trim() &&
|
|
||||||
addressData.country?.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isComplete) {
|
|
||||||
setError("Please fill in all required address fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
|
|
||||||
street: addressData.street,
|
|
||||||
streetLine2: addressData.streetLine2,
|
|
||||||
city: addressData.city,
|
|
||||||
state: addressData.state,
|
|
||||||
postalCode: addressData.postalCode,
|
|
||||||
country: addressData.country,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update local state from authoritative response
|
|
||||||
setBillingInfo(updated);
|
|
||||||
|
|
||||||
setIsEditingAddress(false);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Error updating address");
|
|
||||||
setError(error instanceof Error ? error.message : "Failed to update address");
|
|
||||||
} finally {
|
|
||||||
setIsSavingAddress(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddressChange = (field: keyof Address, value: string) => {
|
|
||||||
setError(null); // Clear error on input
|
|
||||||
setAddressData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
setIsChangingPassword(true);
|
|
||||||
setPwdError(null);
|
|
||||||
setPwdSuccess(null);
|
|
||||||
try {
|
|
||||||
if (!pwdForm.currentPassword || !pwdForm.newPassword) {
|
|
||||||
setPwdError("Please fill in all password fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
|
|
||||||
setPwdError("New password and confirmation do not match");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await useAuthStore.getState().changePassword(pwdForm.currentPassword, pwdForm.newPassword);
|
|
||||||
setPwdSuccess("Password changed successfully.");
|
|
||||||
setPwdForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
|
||||||
} catch (err) {
|
|
||||||
setPwdError(err instanceof Error ? err.message : "Failed to change password");
|
|
||||||
} finally {
|
|
||||||
setIsChangingPassword(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update form data when user data changes (e.g., when Salesforce data loads)
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && !isEditing) {
|
|
||||||
setFormData({
|
|
||||||
firstName: user.firstName || "",
|
|
||||||
lastName: user.lastName || "",
|
|
||||||
email: user.email || "",
|
|
||||||
phone: user.phone || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [user, isEditing]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="py-6">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
<span className="ml-3 text-gray-600">Loading profile...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-6">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full flex items-center justify-center shadow-lg">
|
|
||||||
<span className="text-2xl font-bold text-white">
|
|
||||||
{user?.firstName?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || "U"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
|
||||||
{user?.firstName && user?.lastName
|
|
||||||
? `${user.firstName} ${user.lastName}`
|
|
||||||
: user?.firstName
|
|
||||||
? user.firstName
|
|
||||||
: "Profile"}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-lg text-gray-600">{user?.email}</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Manage your personal information and address
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Banner */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4">
|
|
||||||
<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="space-y-8">
|
|
||||||
{/* Personal Information */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<UserIcon className="h-6 w-6 text-blue-600" />
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
|
|
||||||
</div>
|
|
||||||
{!isEditing && (
|
|
||||||
<button
|
|
||||||
onClick={handleEdit}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<PencilIcon className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
|
||||||
{/* First Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
First Name
|
|
||||||
</label>
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={e => handleInputChange("firstName", e.target.value)}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-900 py-2">
|
|
||||||
{user?.firstName || (
|
|
||||||
<span className="text-gray-500 italic">Not provided</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Last Name
|
|
||||||
</label>
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.lastName}
|
|
||||||
onChange={e => handleInputChange("lastName", e.target.value)}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-900 py-2">
|
|
||||||
{user?.lastName || (
|
|
||||||
<span className="text-gray-500 italic">Not provided</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-base text-gray-900 font-medium">{user?.email}</p>
|
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
|
||||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Verified
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Email cannot be changed. Contact support if you need to update your email
|
|
||||||
address.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Phone Number
|
|
||||||
</label>
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={e => handleInputChange("phone", e.target.value)}
|
|
||||||
placeholder="+81 XX-XXXX-XXXX"
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-900 py-2">
|
|
||||||
{user?.phone || <span className="text-gray-500 italic">Not provided</span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Edit Actions */}
|
|
||||||
{isEditing && (
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-4 w-4 mr-1" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleSave();
|
|
||||||
}}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4 mr-1" />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address Information */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<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">Address Information</h2>
|
|
||||||
</div>
|
|
||||||
{!isEditingAddress && (
|
|
||||||
<button
|
|
||||||
onClick={handleEditAddress}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<PencilIcon className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{isEditingAddress ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Street Address */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Street Address *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addressData.street || ""}
|
|
||||||
onChange={e => handleAddressChange("street", e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
placeholder="123 Main Street"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Street Address Line 2 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Street Address Line 2
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addressData.streetLine2 || ""}
|
|
||||||
onChange={e => handleAddressChange("streetLine2", e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
placeholder="Apartment, suite, etc. (optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* City, State, Postal Code */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
City *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addressData.city || ""}
|
|
||||||
onChange={e => handleAddressChange("city", e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
placeholder="Tokyo"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
State/Prefecture *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addressData.state || ""}
|
|
||||||
onChange={e => handleAddressChange("state", e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
placeholder="Tokyo"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Postal Code *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addressData.postalCode || ""}
|
|
||||||
onChange={e => handleAddressChange("postalCode", e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
placeholder="100-0001"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Country */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Country *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={addressData.country || ""}
|
|
||||||
onChange={e => handleAddressChange("country", e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select Country</option>
|
|
||||||
{(countries as Country[])
|
|
||||||
.sort((a: Country, b: Country) =>
|
|
||||||
a.name.common.localeCompare(b.name.common)
|
|
||||||
)
|
|
||||||
.map((country: Country) => (
|
|
||||||
<option key={country.cca2} value={country.cca2}>
|
|
||||||
{country.name.common}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address Edit Actions */}
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={handleCancelAddress}
|
|
||||||
disabled={isSavingAddress}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-4 w-4 mr-2" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleSaveAddress();
|
|
||||||
}}
|
|
||||||
disabled={isSavingAddress}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{isSavingAddress ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4 mr-2" />
|
|
||||||
Save Address
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{billingInfo?.address &&
|
|
||||||
(billingInfo.address.street || billingInfo.address.city) ? (
|
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<MapPinIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-gray-900 space-y-1">
|
|
||||||
{billingInfo.address.street && (
|
|
||||||
<p className="font-semibold text-gray-900">
|
|
||||||
{billingInfo.address.street}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{billingInfo.address.streetLine2 && (
|
|
||||||
<p className="text-gray-700">{billingInfo.address.streetLine2}</p>
|
|
||||||
)}
|
|
||||||
{(billingInfo.address.city ||
|
|
||||||
billingInfo.address.state ||
|
|
||||||
billingInfo.address.postalCode) && (
|
|
||||||
<p className="text-gray-700">
|
|
||||||
{[
|
|
||||||
billingInfo.address.city,
|
|
||||||
billingInfo.address.state,
|
|
||||||
billingInfo.address.postalCode,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{billingInfo.address.country && (
|
|
||||||
<p className="text-gray-600 font-medium">
|
|
||||||
{(countries as Country[]).find(
|
|
||||||
(c: Country) => c.cca2 === billingInfo.address.country
|
|
||||||
)?.name.common || billingInfo.address.country}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</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={handleEditAddress}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<MapPinIcon className="h-4 w-4 mr-2" />
|
|
||||||
Add Address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Change Password */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Change Password</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
{pwdSuccess && (
|
|
||||||
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
|
|
||||||
{pwdSuccess}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{pwdError && (
|
|
||||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{pwdError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Current Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pwdForm.currentPassword}
|
|
||||||
onChange={e => setPwdForm(p => ({ ...p, currentPassword: e.target.value }))}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
New Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pwdForm.newPassword}
|
|
||||||
onChange={e => setPwdForm(p => ({ ...p, newPassword: e.target.value }))}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
placeholder="New secure password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Confirm New Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pwdForm.confirmPassword}
|
|
||||||
onChange={e => setPwdForm(p => ({ ...p, confirmPassword: e.target.value }))}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
placeholder="Re-enter new password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void handleChangePassword();
|
|
||||||
}}
|
|
||||||
disabled={isChangingPassword}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{isChangingPassword ? "Changing..." : "Change Password"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
|
||||||
Password must be at least 8 characters and include uppercase, lowercase, number,
|
|
||||||
and special character.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,374 +1,5 @@
|
|||||||
"use client";
|
import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
import {
|
|
||||||
ArrowLeftIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
ArrowTopRightOnSquareIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { formatCurrency } from "@/utils/currency";
|
|
||||||
import { useInvoice } from "@/features/billing/hooks";
|
|
||||||
import { createInvoiceSsoLink } from "@/features/billing/hooks";
|
|
||||||
import { InvoiceItemRow } from "@/features/billing/components";
|
|
||||||
|
|
||||||
export default function InvoiceDetailPage() {
|
export default function InvoiceDetailPage() {
|
||||||
const params = useParams();
|
return <InvoiceDetailContainer />;
|
||||||
const [loadingDownload, setLoadingDownload] = useState(false);
|
|
||||||
const [loadingPayment, setLoadingPayment] = useState(false);
|
|
||||||
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
|
|
||||||
|
|
||||||
const invoiceId = parseInt(params.id as string);
|
|
||||||
const { data: invoice, isLoading, error } = useInvoice(invoiceId);
|
|
||||||
|
|
||||||
const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => {
|
|
||||||
void (async () => {
|
|
||||||
if (!invoice) return;
|
|
||||||
|
|
||||||
// Set the appropriate loading state based on target
|
|
||||||
if (target === "download") {
|
|
||||||
setLoadingDownload(true);
|
|
||||||
} else {
|
|
||||||
setLoadingPayment(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ssoLink = await createInvoiceSsoLink(invoice.id, target);
|
|
||||||
if (target === "download") {
|
|
||||||
// For downloads, redirect directly (don't open in new tab)
|
|
||||||
window.location.href = ssoLink.url;
|
|
||||||
} else {
|
|
||||||
// For viewing, open in new tab
|
|
||||||
window.open(ssoLink.url, "_blank");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Failed to create SSO link");
|
|
||||||
// You might want to show a toast notification here
|
|
||||||
} finally {
|
|
||||||
// Reset the appropriate loading state
|
|
||||||
if (target === "download") {
|
|
||||||
setLoadingDownload(false);
|
|
||||||
} else {
|
|
||||||
setLoadingPayment(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManagePaymentMethods = () => {
|
|
||||||
void (async () => {
|
|
||||||
setLoadingPaymentMethods(true);
|
|
||||||
try {
|
|
||||||
const { token } = useAuthStore.getState();
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/auth/sso-link", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
destination: "index.php?rp=/account/paymentmethods",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to create SSO link");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { url } = (await response.json()) as { url: string };
|
|
||||||
window.open(url, "_blank");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Failed to create payment methods SSO link");
|
|
||||||
} finally {
|
|
||||||
setLoadingPaymentMethods(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
|
||||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00")
|
|
||||||
return "N/A";
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
if (isNaN(date.getTime())) return "N/A";
|
|
||||||
return format(date, "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "N/A";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmt = (amount: number, currency: string) => formatCurrency(amount, { currency });
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
||||||
<p className="mt-4 text-gray-600">Loading invoice...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !invoice) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">Error loading invoice</h3>
|
|
||||||
<div className="mt-2 text-sm text-red-700">
|
|
||||||
{error instanceof Error ? error.message : "Invoice not found"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Link href="/billing/invoices" className="text-red-700 hover:text-red-600 font-medium">
|
|
||||||
← Back to invoices
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="py-8">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
|
||||||
{/* Back Button */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Link
|
|
||||||
href="/billing/invoices"
|
|
||||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-5 w-5 mr-2" />
|
|
||||||
Back to Invoices
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invoice Card */}
|
|
||||||
<div className="bg-white rounded-2xl shadow border">
|
|
||||||
{/* Invoice Header */}
|
|
||||||
<div className="px-8 py-6 border-b border-gray-200">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
|
||||||
{/* Left: Invoice Info */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Invoice #{invoice.number}</h1>
|
|
||||||
{/* Harmonize with StatusPill while keeping existing badge for now */}
|
|
||||||
<StatusPill
|
|
||||||
label={invoice.status}
|
|
||||||
variant={
|
|
||||||
invoice.status === "Paid"
|
|
||||||
? "success"
|
|
||||||
: invoice.status === "Overdue"
|
|
||||||
? "error"
|
|
||||||
: invoice.status === "Unpaid"
|
|
||||||
? "warning"
|
|
||||||
: "neutral"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Issued:</span>
|
|
||||||
<span className="ml-2 px-2.5 py-1 text-xs font-bold rounded-md bg-blue-100 text-blue-800 border border-blue-200">
|
|
||||||
{formatDate(invoice.issuedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{invoice.dueDate && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Due:</span>
|
|
||||||
<span
|
|
||||||
className={`ml-2 px-2.5 py-1 text-xs font-bold rounded-md ${
|
|
||||||
invoice.status === "Overdue"
|
|
||||||
? "bg-red-100 text-red-800 border border-red-200"
|
|
||||||
: invoice.status === "Unpaid"
|
|
||||||
? "bg-amber-100 text-amber-800 border border-amber-200"
|
|
||||||
: "bg-gray-100 text-gray-700 border border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatDate(invoice.dueDate)}
|
|
||||||
{invoice.status === "Overdue" && " • OVERDUE"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Actions */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 min-w-0">
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreateSsoLink("download")}
|
|
||||||
disabled={loadingDownload}
|
|
||||||
className="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{loadingDownload ? (
|
|
||||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600 mr-1.5"></div>
|
|
||||||
) : (
|
|
||||||
<ArrowDownTrayIcon className="h-3 w-3 mr-1.5" />
|
|
||||||
)}
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(invoice.status === "Unpaid" || invoice.status === "Overdue") && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleManagePaymentMethods}
|
|
||||||
disabled={loadingPaymentMethods}
|
|
||||||
className="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{loadingPaymentMethods ? (
|
|
||||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600 mr-1.5"></div>
|
|
||||||
) : (
|
|
||||||
<ServerIcon className="h-3 w-3 mr-1.5" />
|
|
||||||
)}
|
|
||||||
Payment
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreateSsoLink("pay")}
|
|
||||||
disabled={loadingPayment}
|
|
||||||
className={`inline-flex items-center justify-center px-5 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white transition-all duration-200 shadow-md whitespace-nowrap ${
|
|
||||||
invoice.status === "Overdue"
|
|
||||||
? "bg-red-600 hover:bg-red-700 ring-2 ring-red-200 hover:ring-red-300"
|
|
||||||
: "bg-blue-600 hover:bg-blue-700 hover:shadow-lg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loadingPayment ? (
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
) : (
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Paid Status Banner */}
|
|
||||||
{invoice.status === "Paid" && (
|
|
||||||
<div className="mt-6 flex items-center text-green-700 bg-green-50 border border-green-200 px-4 py-3 rounded-lg">
|
|
||||||
<CheckCircleIcon className="h-5 w-5 mr-3" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-semibold">Invoice Paid</span>
|
|
||||||
<span className="ml-2">
|
|
||||||
• Paid on {formatDate(invoice.paidDate || invoice.issuedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invoice Body */}
|
|
||||||
<div className="px-8 py-6 space-y-6">
|
|
||||||
{/* Items */}
|
|
||||||
<SubCard title="Items & Services">
|
|
||||||
{invoice.items && invoice.items.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{invoice.items.map((item: import("@customer-portal/shared").InvoiceItem) => (
|
|
||||||
<InvoiceItemRow
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
description={item.description}
|
|
||||||
amount={item.amount || 0}
|
|
||||||
currency={invoice.currency}
|
|
||||||
quantity={item.quantity}
|
|
||||||
serviceId={item.serviceId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-gray-600">No items found on this invoice.</div>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<SubCard title="Totals">
|
|
||||||
<div className="max-w-xs ml-auto">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span className="font-medium">{fmt(invoice.subtotal, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
{invoice.tax > 0 && (
|
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
|
||||||
<span>Tax</span>
|
|
||||||
<span className="font-medium">{fmt(invoice.tax, invoice.currency)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="border-t border-gray-300 pt-2 mt-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-base font-semibold text-gray-900">Total</span>
|
|
||||||
<span className="text-2xl font-bold text-gray-900">
|
|
||||||
{fmt(invoice.total, invoice.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{(invoice.status === "Unpaid" || invoice.status === "Overdue") && (
|
|
||||||
<SubCard title="Payment">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleManagePaymentMethods}
|
|
||||||
disabled={loadingPaymentMethods}
|
|
||||||
className="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{loadingPaymentMethods ? (
|
|
||||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600 mr-1.5"></div>
|
|
||||||
) : (
|
|
||||||
<ServerIcon className="h-3 w-3 mr-1.5" />
|
|
||||||
)}
|
|
||||||
Payment Methods
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreateSsoLink("pay")}
|
|
||||||
disabled={loadingPayment}
|
|
||||||
className={`inline-flex items-center justify-center px-5 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white transition-all duration-200 shadow-md whitespace-nowrap ${
|
|
||||||
invoice.status === "Overdue"
|
|
||||||
? "bg-red-600 hover:bg-red-700 ring-2 ring-red-200 hover:ring-red-300"
|
|
||||||
: "bg-blue-600 hover:bg-blue-700 hover:shadow-lg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loadingPayment ? (
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
) : (
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,306 +1,5 @@
|
|||||||
"use client";
|
import InvoicesListContainer from "@/features/billing/views/InvoicesList";
|
||||||
|
|
||||||
import React, { useState, useMemo } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
|
||||||
import { SearchFilterBar } from "@/components/ui/search-filter-bar";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-skeleton";
|
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
|
||||||
import { EmptyState, SearchEmptyState, FilterEmptyState } from "@/components/ui/empty-state";
|
|
||||||
import {
|
|
||||||
CreditCardIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
ArrowTopRightOnSquareIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useInvoices } from "@/hooks/useInvoices";
|
|
||||||
import type { Invoice } from "@customer-portal/shared";
|
|
||||||
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
const router = useRouter();
|
return <InvoicesListContainer />;
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const itemsPerPage = 10;
|
|
||||||
|
|
||||||
// Fetch invoices from API
|
|
||||||
const {
|
|
||||||
data: invoiceData,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = useInvoices({
|
|
||||||
page: currentPage,
|
|
||||||
limit: itemsPerPage,
|
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pagination = invoiceData?.pagination;
|
|
||||||
|
|
||||||
// Filter invoices based on search (status filtering is done in API)
|
|
||||||
const filteredInvoices = useMemo(() => {
|
|
||||||
const invoices = invoiceData?.invoices || [];
|
|
||||||
if (!searchTerm) return invoices;
|
|
||||||
return invoices.filter(invoice => {
|
|
||||||
const matchesSearch =
|
|
||||||
invoice.number.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(invoice.description &&
|
|
||||||
invoice.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
||||||
return matchesSearch;
|
|
||||||
});
|
|
||||||
}, [invoiceData?.invoices, searchTerm]);
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Paid":
|
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
|
||||||
case "Unpaid":
|
|
||||||
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
|
|
||||||
case "Overdue":
|
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
|
|
||||||
case "Cancelled":
|
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-gray-500" />;
|
|
||||||
default:
|
|
||||||
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Paid":
|
|
||||||
return "success" as const;
|
|
||||||
case "Unpaid":
|
|
||||||
return "warning" as const;
|
|
||||||
case "Overdue":
|
|
||||||
return "error" as const;
|
|
||||||
case "Cancelled":
|
|
||||||
return "neutral" as const;
|
|
||||||
default:
|
|
||||||
return "neutral" as const;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusFilterOptions = [
|
|
||||||
{ value: "all", label: "All Status" },
|
|
||||||
{ value: "Unpaid", label: "Unpaid" },
|
|
||||||
{ value: "Paid", label: "Paid" },
|
|
||||||
{ value: "Overdue", label: "Overdue" },
|
|
||||||
{ value: "Cancelled", label: "Cancelled" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const invoiceColumns = [
|
|
||||||
{
|
|
||||||
key: "invoice",
|
|
||||||
header: "Invoice",
|
|
||||||
render: (invoice: Invoice) => (
|
|
||||||
<div className="flex items-center">
|
|
||||||
{getStatusIcon(invoice.status)}
|
|
||||||
<div className="ml-3">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{invoice.number}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
header: "Status",
|
|
||||||
render: (invoice: Invoice) => (
|
|
||||||
<StatusPill label={invoice.status} variant={getStatusVariant(invoice.status)} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "amount",
|
|
||||||
header: "Amount",
|
|
||||||
render: (invoice: Invoice) => (
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{formatCurrency(invoice.total, {
|
|
||||||
currency: invoice.currency,
|
|
||||||
currencySymbol: invoice.currencySymbol,
|
|
||||||
locale: getCurrencyLocale(invoice.currency),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "invoiceDate",
|
|
||||||
header: "Invoice Date",
|
|
||||||
render: (invoice: Invoice) => (
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{invoice.issuedAt ? format(new Date(invoice.issuedAt), "MMM d, yyyy") : "N/A"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "dueDate",
|
|
||||||
header: "Due Date",
|
|
||||||
render: (invoice: Invoice) => (
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{invoice.dueDate ? format(new Date(invoice.dueDate), "MMM d, yyyy") : "N/A"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "relative",
|
|
||||||
render: (invoice: Invoice) => (
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
<Link
|
|
||||||
href={`/billing/invoices/${invoice.id}`}
|
|
||||||
className="text-blue-600 hover:text-blue-900"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<CreditCardIcon />}
|
|
||||||
title="Invoices"
|
|
||||||
description="Manage and view your billing invoices"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<LoadingSpinner size="lg" />
|
|
||||||
<p className="text-muted-foreground">Loading invoices...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<CreditCardIcon />}
|
|
||||||
title="Invoices"
|
|
||||||
description="Manage and view your billing invoices"
|
|
||||||
>
|
|
||||||
<ErrorState
|
|
||||||
title="Error loading invoices"
|
|
||||||
message={error instanceof Error ? error.message : "An unexpected error occurred"}
|
|
||||||
variant="page"
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<CreditCardIcon />}
|
|
||||||
title="Invoices"
|
|
||||||
description="Manage and view your billing invoices"
|
|
||||||
>
|
|
||||||
{/* Invoice Table with integrated header filters */}
|
|
||||||
<SubCard
|
|
||||||
header={
|
|
||||||
<SearchFilterBar
|
|
||||||
searchValue={searchTerm}
|
|
||||||
onSearchChange={setSearchTerm}
|
|
||||||
searchPlaceholder="Search invoices..."
|
|
||||||
filterValue={statusFilter}
|
|
||||||
onFilterChange={value => {
|
|
||||||
setStatusFilter(value);
|
|
||||||
setCurrentPage(1); // Reset to first page when filtering
|
|
||||||
}}
|
|
||||||
filterOptions={statusFilterOptions}
|
|
||||||
filterLabel="Filter by status"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
|
|
||||||
footer={
|
|
||||||
pagination && filteredInvoices.length > 0 ? (
|
|
||||||
<div className="px-1 sm:px-0 py-1 flex items-center justify-between">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))
|
|
||||||
}
|
|
||||||
disabled={currentPage === (pagination?.totalPages || 1)}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}
|
|
||||||
</span>{" "}
|
|
||||||
of <span className="font-medium">{pagination?.totalItems || 0}</span> results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav
|
|
||||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
|
||||||
aria-label="Pagination"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))
|
|
||||||
}
|
|
||||||
disabled={currentPage === (pagination?.totalPages || 1)}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataTable
|
|
||||||
data={filteredInvoices}
|
|
||||||
columns={invoiceColumns}
|
|
||||||
emptyState={{
|
|
||||||
icon: <DocumentTextIcon className="h-12 w-12" />,
|
|
||||||
title: "No invoices found",
|
|
||||||
description:
|
|
||||||
searchTerm && statusFilter !== "all"
|
|
||||||
? "No invoices match your search and filter criteria."
|
|
||||||
: searchTerm
|
|
||||||
? "No invoices match your search."
|
|
||||||
: statusFilter !== "all"
|
|
||||||
? "No invoices match your filter criteria."
|
|
||||||
: "No invoices have been generated yet.",
|
|
||||||
}}
|
|
||||||
onRowClick={invoice => router.push(`/billing/invoices/${invoice.id}`)}
|
|
||||||
/>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{/* Pagination moved to SubCard footer above */}
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,175 +1,5 @@
|
|||||||
"use client";
|
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
import { authenticatedApi, ApiError } from "@/lib/api";
|
|
||||||
import { usePaymentRefresh } from "@/hooks/usePaymentRefresh";
|
|
||||||
import {
|
|
||||||
CreditCardIcon,
|
|
||||||
ArrowTopRightOnSquareIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { InlineToast } from "@/components/ui/inline-toast";
|
|
||||||
|
|
||||||
export default function PaymentMethodsPage() {
|
export default function PaymentMethodsPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
return <PaymentMethodsContainer />;
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { token } = useAuthStore();
|
|
||||||
const paymentRefresh = usePaymentRefresh({
|
|
||||||
// Lightweight refresh: we only care about the count here
|
|
||||||
refetch: async () => ({
|
|
||||||
data: await authenticatedApi.get<{ totalCount: number; paymentMethods: unknown[] }>(
|
|
||||||
"/invoices/payment-methods"
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
|
|
||||||
attachFocusListeners: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const openPaymentMethods = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
setError("Please log in to access payment methods.");
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { url } = await authenticatedApi.post<{ url: string }>("/auth/sso-link", {
|
|
||||||
destination: "index.php?rp=/account/paymentmethods",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open in new tab to avoid back button issues
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
|
||||||
setIsLoading(false);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Failed to open payment methods");
|
|
||||||
// Simplified, no WHMCS linking prompts
|
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
|
||||||
setError("Authentication failed. Please log in again.");
|
|
||||||
} else {
|
|
||||||
setError("Unable to access payment methods. Please try again later.");
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// When returning from WHMCS tab, auto-refresh payment methods and show a short toast
|
|
||||||
useEffect(() => {
|
|
||||||
// token gate only (hook already checks focus listeners)
|
|
||||||
if (!token) return;
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
// Show error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<CreditCardIcon />}
|
|
||||||
title="Payment Methods"
|
|
||||||
description="Manage your saved payment methods and billing information"
|
|
||||||
>
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<div className="bg-white rounded-lg shadow border">
|
|
||||||
<div className="text-center px-6 py-8">
|
|
||||||
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
|
||||||
<ExclamationTriangleIcon className="w-6 h-6 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Unable to Access Payment Methods
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-2">{error}</p>
|
|
||||||
<p className="text-gray-500">Please try again later.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main payment methods page
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<CreditCardIcon />}
|
|
||||||
title="Payment Methods"
|
|
||||||
description="Manage your saved payment methods and billing information"
|
|
||||||
>
|
|
||||||
<InlineToast
|
|
||||||
visible={paymentRefresh.toast.visible}
|
|
||||||
text={paymentRefresh.toast.text}
|
|
||||||
tone={paymentRefresh.toast.tone}
|
|
||||||
/>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Payment Methods Card */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<div className="bg-white rounded-lg shadow border">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mx-auto w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6">
|
|
||||||
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Manage Payment Methods</h2>
|
|
||||||
<p className="text-gray-600 mb-8">
|
|
||||||
Access your secure payment methods dashboard to add, edit, or remove payment
|
|
||||||
options.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void openPaymentMethods();
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
Opening...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowTopRightOnSquareIcon className="w-4 h-4" />
|
|
||||||
Open Payment Methods
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 mt-4">Opens in a new tab for security</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Security Info Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<CreditCardIcon className="h-5 w-5 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-blue-800">Secure & Encrypted</h3>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
All payment information is securely encrypted and protected with industry-standard
|
|
||||||
security.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-800 mb-2">Supported Payment Methods</h3>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
|
||||||
<li>• Debit Cards</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,762 +1,5 @@
|
|||||||
"use client";
|
import InternetConfigureContainer from "@/features/catalog/views/InternetConfigure";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import { ServerIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
import { InternetPlan, InternetAddon, InternetInstallation } from "@/shared/types/catalog.types";
|
|
||||||
|
|
||||||
import { AddonGroup } from "@/components/catalog/addon-group";
|
|
||||||
import { InstallationOptions } from "@/components/catalog/installation-options";
|
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
|
||||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
|
||||||
import { ProgressSteps } from "@/components/catalog/progress-steps";
|
|
||||||
import { StepHeader } from "@/components/catalog/step-header";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
|
|
||||||
type AccessMode = "IPoE-BYOR" | "PPPoE";
|
|
||||||
type InstallPlan = string; // Now dynamic from Salesforce
|
|
||||||
|
|
||||||
function InternetConfigureContent() {
|
|
||||||
const [plan, setPlan] = useState<InternetPlan | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const planSku = searchParams.get("plan");
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<AccessMode | null>(null);
|
|
||||||
const [installPlan, setInstallPlan] = useState<InstallPlan | null>(null);
|
|
||||||
const [addons, setAddons] = useState<InternetAddon[]>([]);
|
|
||||||
const [installations, setInstallations] = useState<InternetInstallation[]>([]);
|
|
||||||
const [selectedAddonSkus, setSelectedAddonSkus] = useState<string[]>([]);
|
|
||||||
const [currentStep, setCurrentStep] = useState(() => {
|
|
||||||
const stepParam = searchParams.get("step");
|
|
||||||
return stepParam ? parseInt(stepParam, 10) : 1;
|
|
||||||
});
|
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
||||||
|
|
||||||
// Handle addon selection using the reusable component
|
|
||||||
const handleAddonSelection = (newSelectedSkus: string[]) => {
|
|
||||||
setSelectedAddonSkus(newSelectedSkus);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Smooth step transition function - preserves user data
|
|
||||||
const transitionToStep = (nextStep: number) => {
|
|
||||||
setIsTransitioning(true);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCurrentStep(nextStep);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsTransitioning(false);
|
|
||||||
}, 50);
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void (async () => {
|
|
||||||
if (!planSku) {
|
|
||||||
router.push("/catalog/internet");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the specific plan details, add-ons, and installations in parallel
|
|
||||||
const [plans, addonsData, installationsData] = await Promise.all([
|
|
||||||
authenticatedApi.get<InternetPlan[]>("/catalog/internet/plans"),
|
|
||||||
authenticatedApi.get<InternetAddon[]>("/catalog/internet/addons"),
|
|
||||||
authenticatedApi.get<InternetInstallation[]>("/catalog/internet/installations"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
const selectedPlan = plans.find(p => p.sku === planSku);
|
|
||||||
if (selectedPlan) {
|
|
||||||
setPlan(selectedPlan);
|
|
||||||
setAddons(addonsData);
|
|
||||||
setInstallations(installationsData);
|
|
||||||
|
|
||||||
// Restore state from URL parameters
|
|
||||||
const accessModeParam = searchParams.get("accessMode");
|
|
||||||
if (accessModeParam) {
|
|
||||||
setMode(accessModeParam as AccessMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const installationSkuParam = searchParams.get("installationSku");
|
|
||||||
if (installationSkuParam) {
|
|
||||||
const installation = installationsData.find(i => i.sku === installationSkuParam);
|
|
||||||
if (installation) {
|
|
||||||
setInstallPlan(installation.catalogMetadata.installationTerm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore selected addons from URL parameters
|
|
||||||
const addonSkuParams = searchParams.getAll("addonSku");
|
|
||||||
if (addonSkuParams.length > 0) {
|
|
||||||
setSelectedAddonSkus(addonSkuParams);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
router.push("/catalog/internet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to load plan, add-ons, or installations:", e);
|
|
||||||
if (mounted) router.push("/catalog/internet");
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [planSku, router, searchParams]);
|
|
||||||
|
|
||||||
// const canContinue = plan && mode && installPlan;
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ number: 1, title: "Service Details", completed: currentStep > 1 },
|
|
||||||
{ number: 2, title: "Installation", completed: currentStep > 2 },
|
|
||||||
{ number: 3, title: "Add-ons", completed: currentStep > 3 },
|
|
||||||
{ number: 4, title: "Review Order", completed: currentStep > 4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
const calculateTotals = () => {
|
|
||||||
let monthlyTotal = plan?.monthlyPrice || 0;
|
|
||||||
let oneTimeTotal = 0;
|
|
||||||
|
|
||||||
// Add selected addons
|
|
||||||
selectedAddonSkus.forEach(addonSku => {
|
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
|
||||||
if (!addon) return;
|
|
||||||
|
|
||||||
const monthlyCharge = addon.monthlyPrice ?? (addon.billingCycle === "Monthly" ? addon.unitPrice : undefined);
|
|
||||||
const oneTimeCharge = addon.oneTimePrice ?? (addon.billingCycle !== "Monthly" ? addon.unitPrice : undefined);
|
|
||||||
|
|
||||||
if (typeof monthlyCharge === "number") {
|
|
||||||
monthlyTotal += monthlyCharge;
|
|
||||||
}
|
|
||||||
if (typeof oneTimeCharge === "number") {
|
|
||||||
oneTimeTotal += oneTimeCharge;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add installation cost
|
|
||||||
const installation = installations.find(
|
|
||||||
i => i.catalogMetadata.installationTerm === installPlan
|
|
||||||
);
|
|
||||||
if (installation) {
|
|
||||||
const monthlyCharge =
|
|
||||||
installation.monthlyPrice ?? (installation.billingCycle === "Monthly" ? installation.unitPrice : undefined);
|
|
||||||
const oneTimeCharge =
|
|
||||||
installation.oneTimePrice ?? (installation.billingCycle !== "Monthly" ? installation.unitPrice : undefined);
|
|
||||||
|
|
||||||
if (typeof monthlyCharge === "number") {
|
|
||||||
monthlyTotal += monthlyCharge;
|
|
||||||
}
|
|
||||||
if (typeof oneTimeCharge === "number") {
|
|
||||||
oneTimeTotal += oneTimeCharge;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { monthlyTotal, oneTimeTotal };
|
|
||||||
};
|
|
||||||
|
|
||||||
const { monthlyTotal, oneTimeTotal } = calculateTotals();
|
|
||||||
|
|
||||||
const handleConfirmOrder = () => {
|
|
||||||
if (!plan || !mode || !installPlan) return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
type: "internet",
|
|
||||||
plan: plan.sku,
|
|
||||||
accessMode: mode,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add installation SKU (not type)
|
|
||||||
const installation = installations.find(
|
|
||||||
i => i.catalogMetadata.installationTerm === installPlan
|
|
||||||
);
|
|
||||||
if (installation) {
|
|
||||||
params.append("installationSku", installation.sku);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add addon SKUs
|
|
||||||
if (selectedAddonSkus.length > 0) {
|
|
||||||
selectedAddonSkus.forEach(addonSku => {
|
|
||||||
params.append("addonSku", addonSku);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/checkout?${params.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ServerIcon />}
|
|
||||||
title="Configure Internet Service"
|
|
||||||
description="Set up your internet service options"
|
|
||||||
>
|
|
||||||
<LoadingSpinner message="Loading configuration..." />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ServerIcon />}
|
|
||||||
title="Configure Internet Service"
|
|
||||||
description="Set up your internet service options"
|
|
||||||
>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-600 mb-4">Plan not found</p>
|
|
||||||
<AnimatedButton href="/catalog/internet" className="flex items-center">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Internet Plans
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout icon={<></>} title="" description="">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<AnimatedButton
|
|
||||||
href="/catalog/internet"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mb-6 group"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform" />
|
|
||||||
Back to Internet Plans
|
|
||||||
</AnimatedButton>
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Configure {plan.name}</h1>
|
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-3 bg-gray-50 px-6 py-3 rounded-2xl border">
|
|
||||||
<div
|
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
||||||
plan.internetPlanTier === "Platinum"
|
|
||||||
? "bg-purple-100 text-purple-800"
|
|
||||||
: plan.internetPlanTier === "Gold"
|
|
||||||
? "bg-yellow-100 text-yellow-800"
|
|
||||||
: "bg-gray-100 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plan.internetPlanTier || "Plan"}
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-600">•</span>
|
|
||||||
<span className="font-medium text-gray-900">{plan.name}</span>
|
|
||||||
{plan.monthlyPrice && (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-600">•</span>
|
|
||||||
<span className="font-bold text-gray-900">
|
|
||||||
¥{plan.monthlyPrice.toLocaleString()}/month
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Steps */}
|
|
||||||
<ProgressSteps steps={steps} currentStep={currentStep} />
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Step 1: Service Configuration */}
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Service Configuration</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 ml-11">Review your plan details and configuration</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Important Message for Platinum */}
|
|
||||||
{plan?.internetPlanTier === "Platinum" && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-yellow-600 mt-0.5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h5 className="font-medium text-yellow-900">
|
|
||||||
IMPORTANT - For PLATINUM subscribers
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-yellow-800 mt-1">
|
|
||||||
Additional fees are incurred for the PLATINUM service. Please refer to the
|
|
||||||
information from our tech team for details.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-700 mt-2">
|
|
||||||
* Will appear on the invoice as "Platinum Base Plan". Device
|
|
||||||
subscriptions will be added later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Access Mode Selection - Only for Silver */}
|
|
||||||
{plan?.internetPlanTier === "Silver" ? (
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-4">
|
|
||||||
Select Your Router & ISP Configuration:
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setMode("PPPoE")}
|
|
||||||
className={`p-6 rounded-xl border-2 text-left transition-all duration-300 ease-in-out transform hover:scale-[1.02] hover:shadow-md ${
|
|
||||||
mode === "PPPoE"
|
|
||||||
? "border-orange-500 bg-orange-50 shadow-md scale-[1.02]"
|
|
||||||
: "border-gray-200 hover:border-orange-300 hover:bg-orange-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h5 className="font-semibold text-gray-900">Any Router + PPPoE</h5>
|
|
||||||
<div
|
|
||||||
className={`w-4 h-4 rounded-full border-2 ${
|
|
||||||
mode === "PPPoE" ? "bg-orange-500 border-orange-500" : "border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mode === "PPPoE" && (
|
|
||||||
<svg
|
|
||||||
className="w-full h-full text-white"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
Works with most routers you already own or can purchase anywhere.
|
|
||||||
</p>
|
|
||||||
<div className="bg-orange-100 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-orange-800">
|
|
||||||
<strong>Note:</strong> PPPoE may experience network congestion during peak
|
|
||||||
hours, potentially resulting in slower speeds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setMode("IPoE-BYOR")}
|
|
||||||
className={`p-6 rounded-xl border-2 text-left transition-all duration-300 ease-in-out transform hover:scale-[1.02] hover:shadow-md ${
|
|
||||||
mode === "IPoE-BYOR"
|
|
||||||
? "border-green-500 bg-green-50 shadow-md scale-[1.02]"
|
|
||||||
: "border-gray-200 hover:border-green-300 hover:bg-green-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h5 className="font-semibold text-gray-900">v6plus Router + IPoE</h5>
|
|
||||||
<div
|
|
||||||
className={`w-4 h-4 rounded-full border-2 ${
|
|
||||||
mode === "IPoE-BYOR"
|
|
||||||
? "bg-green-500 border-green-500"
|
|
||||||
: "border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mode === "IPoE-BYOR" && (
|
|
||||||
<svg
|
|
||||||
className="w-full h-full text-white"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
Requires a v6plus-compatible router for faster, more stable connection.
|
|
||||||
</p>
|
|
||||||
<div className="bg-green-100 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-green-800">
|
|
||||||
<strong>Recommended:</strong> Faster speeds with less congestion.
|
|
||||||
<a
|
|
||||||
href="https://www.jpix.ad.jp/service/?p=3565"
|
|
||||||
target="_blank"
|
|
||||||
className="text-blue-600 underline ml-1"
|
|
||||||
>
|
|
||||||
Check compatibility →
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Continue Button */}
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => {
|
|
||||||
if (!mode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
transitionToStep(2);
|
|
||||||
}}
|
|
||||||
disabled={!mode}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
Continue to Installation
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-green-600 mr-2"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium text-green-900">
|
|
||||||
Access Mode: IPoE-HGW (Pre-configured for {plan?.internetPlanTier} plan)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => {
|
|
||||||
setMode("IPoE-BYOR");
|
|
||||||
transitionToStep(2);
|
|
||||||
}}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
Continue to Installation
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Installation */}
|
|
||||||
{currentStep === 2 && mode && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={2}
|
|
||||||
title="Installation Options"
|
|
||||||
description="Choose your installation payment plan"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InstallationOptions
|
|
||||||
installations={installations}
|
|
||||||
selectedInstallation={
|
|
||||||
installations.find(
|
|
||||||
inst => inst.catalogMetadata.installationTerm === installPlan
|
|
||||||
) || null
|
|
||||||
}
|
|
||||||
onInstallationSelect={installation => {
|
|
||||||
setInstallPlan(installation.catalogMetadata.installationTerm);
|
|
||||||
}}
|
|
||||||
showSkus={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-blue-600 mt-0.5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-900">Weekend Installation</h4>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Weekend installation is available with an additional ¥3,000 charge. Our team
|
|
||||||
will contact you to schedule the most convenient time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Continue Button */}
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(1)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Service Details
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => {
|
|
||||||
if (!installPlan) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
transitionToStep(3);
|
|
||||||
}}
|
|
||||||
disabled={!installPlan}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
Continue to Add-ons
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Add-ons */}
|
|
||||||
{currentStep === 3 && installPlan && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={3}
|
|
||||||
title="Add-ons"
|
|
||||||
description="Optional services to enhance your internet experience"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddonGroup
|
|
||||||
addons={addons}
|
|
||||||
selectedAddonSkus={selectedAddonSkus}
|
|
||||||
onAddonToggle={handleAddonSelection}
|
|
||||||
showSkus={false}
|
|
||||||
/>
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(2)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Installation
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton onClick={() => transitionToStep(4)} className="flex items-center">
|
|
||||||
Review Order
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 4: Review Order */}
|
|
||||||
{currentStep === 4 && plan && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={4}
|
|
||||||
title="Review Your Order"
|
|
||||||
description="Review your configuration and proceed to checkout"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Receipt-Style Order Summary */}
|
|
||||||
<div className="max-w-lg mx-auto mb-8 bg-gradient-to-b from-white to-gray-50 shadow-xl rounded-lg border border-gray-200 p-6">
|
|
||||||
{/* Receipt Header */}
|
|
||||||
<div className="text-center border-b-2 border-dashed border-gray-300 pb-4 mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-1">Order Summary</h3>
|
|
||||||
<p className="text-sm text-gray-500">Review your configuration</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Details */}
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-gray-900">{plan.name}</h4>
|
|
||||||
<p className="text-sm text-gray-600">Internet Service</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-semibold text-gray-900">
|
|
||||||
¥{plan.monthlyPrice?.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">per month</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configuration Details */}
|
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Configuration</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Access Mode:</span>
|
|
||||||
<span className="text-gray-900">{mode || "Not selected"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add-ons */}
|
|
||||||
{selectedAddonSkus.length > 0 && (
|
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Add-ons</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{selectedAddonSkus.map(addonSku => {
|
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
|
||||||
const monthlyAmount =
|
|
||||||
addon?.monthlyPrice ?? (addon?.billingCycle === "Monthly" ? addon?.unitPrice : undefined);
|
|
||||||
const oneTimeAmount =
|
|
||||||
addon?.oneTimePrice ?? (addon?.billingCycle !== "Monthly" ? addon?.unitPrice : undefined);
|
|
||||||
const amount = monthlyAmount ?? oneTimeAmount ?? 0;
|
|
||||||
const cadence = monthlyAmount ? "mo" : "once";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={addonSku} className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">{addon?.name || addonSku}</span>
|
|
||||||
<span className="text-gray-900">
|
|
||||||
¥{amount.toLocaleString()}
|
|
||||||
<span className="text-xs text-gray-500 ml-1">/{cadence}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Installation Fees */}
|
|
||||||
{(() => {
|
|
||||||
const installation = installations.find(
|
|
||||||
i => i.catalogMetadata.installationTerm === installPlan
|
|
||||||
);
|
|
||||||
if (!installation) return null;
|
|
||||||
|
|
||||||
const monthlyAmount =
|
|
||||||
installation.monthlyPrice ?? (installation.billingCycle === "Monthly" ? installation.unitPrice : undefined);
|
|
||||||
const oneTimeAmount =
|
|
||||||
installation.oneTimePrice ?? (installation.billingCycle !== "Monthly" ? installation.unitPrice : undefined);
|
|
||||||
const amount = monthlyAmount ?? oneTimeAmount;
|
|
||||||
|
|
||||||
return amount && amount > 0 ? (
|
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">{installation.name}</span>
|
|
||||||
<span className="text-gray-900">
|
|
||||||
¥{amount.toLocaleString()}
|
|
||||||
<span className="text-xs text-gray-500 ml-1">
|
|
||||||
/{monthlyAmount ? "mo" : "once"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-xl font-bold">
|
|
||||||
<span className="text-gray-900">Monthly Total</span>
|
|
||||||
<span className="text-blue-600">¥{monthlyTotal.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
{oneTimeTotal > 0 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">One-time Total</span>
|
|
||||||
<span className="text-orange-600 font-semibold">
|
|
||||||
¥{oneTimeTotal.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Receipt Footer */}
|
|
||||||
<div className="text-center mt-6 pt-4 border-t border-gray-200">
|
|
||||||
<p className="text-xs text-gray-500">High-speed internet service</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-between items-center pt-6 border-t">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(3)}
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="px-8 py-4 text-lg"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
|
||||||
Back to Add-ons
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={handleConfirmOrder}
|
|
||||||
size="lg"
|
|
||||||
className="px-12 py-4 text-lg font-semibold"
|
|
||||||
>
|
|
||||||
Proceed to Checkout
|
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InternetConfigurePage() {
|
export default function InternetConfigurePage() {
|
||||||
return (
|
return <InternetConfigureContainer />;
|
||||||
<Suspense fallback={<LoadingSpinner message="Loading internet configuration..." />}>
|
|
||||||
<InternetConfigureContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,325 +1,5 @@
|
|||||||
"use client";
|
import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import {
|
|
||||||
ServerIcon,
|
|
||||||
CurrencyYenIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
WifiIcon,
|
|
||||||
HomeIcon,
|
|
||||||
BuildingOfficeIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
import { InternetPlan, InternetInstallation } from "@/shared/types/catalog.types";
|
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
|
||||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
|
||||||
|
|
||||||
export default function InternetPlansPage() {
|
export default function InternetPlansPage() {
|
||||||
const [plans, setPlans] = useState<InternetPlan[]>([]);
|
return <InternetPlansContainer />;
|
||||||
const [installations, setInstallations] = useState<InternetInstallation[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [eligibility, setEligibility] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const [plans, installations] = await Promise.all([
|
|
||||||
authenticatedApi.get<InternetPlan[]>("/catalog/internet/plans"),
|
|
||||||
authenticatedApi.get<InternetInstallation[]>("/catalog/internet/installations"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
// Plans are now ordered by Salesforce Display_Order__c field
|
|
||||||
setPlans(plans);
|
|
||||||
setInstallations(installations);
|
|
||||||
if (plans.length > 0) {
|
|
||||||
setEligibility(plans[0].internetOfferingType || "Home 1G");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load plans");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getEligibilityIcon = (offeringType?: string) => {
|
|
||||||
const lower = (offeringType || "").toLowerCase();
|
|
||||||
if (lower.includes("home")) {
|
|
||||||
return <HomeIcon className="h-5 w-5" />;
|
|
||||||
}
|
|
||||||
if (lower.includes("apartment")) {
|
|
||||||
return <BuildingOfficeIcon className="h-5 w-5" />;
|
|
||||||
}
|
|
||||||
return <HomeIcon className="h-5 w-5" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEligibilityColor = (offeringType?: string) => {
|
|
||||||
const lower = (offeringType || "").toLowerCase();
|
|
||||||
if (lower.includes("home")) {
|
|
||||||
return "text-blue-600 bg-blue-50 border-blue-200";
|
|
||||||
}
|
|
||||||
if (lower.includes("apartment")) {
|
|
||||||
return "text-green-600 bg-green-50 border-green-200";
|
|
||||||
}
|
|
||||||
return "text-gray-600 bg-gray-50 border-gray-200";
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Internet Plans"
|
|
||||||
description="Loading your personalized plans..."
|
|
||||||
icon={<WifiIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<LoadingSpinner message="Loading your personalized plans..." />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Internet Plans"
|
|
||||||
description="Error loading plans"
|
|
||||||
icon={<WifiIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="rounded-lg bg-red-50 border border-red-200 p-6">
|
|
||||||
<div className="text-red-800 font-medium">Failed to load plans</div>
|
|
||||||
<div className="text-red-600 text-sm mt-1">{error}</div>
|
|
||||||
<AnimatedButton href="/catalog" className="flex items-center mt-4">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Internet Plans"
|
|
||||||
description="High-speed internet services for your home or business"
|
|
||||||
icon={<WifiIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<AnimatedButton href="/catalog" variant="outline" size="sm" className="group">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your Internet Plan</h1>
|
|
||||||
|
|
||||||
{eligibility && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center gap-2 px-6 py-3 rounded-2xl border ${getEligibilityColor(eligibility)}`}
|
|
||||||
>
|
|
||||||
{getEligibilityIcon(eligibility)}
|
|
||||||
<span className="font-medium">Available for: {eligibility}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-2 max-w-2xl mx-auto">
|
|
||||||
Plans shown are tailored to your house type and local infrastructure
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
{plans.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{plans.map(plan => (
|
|
||||||
<InternetPlanCard key={plan.id} plan={plan} installations={installations} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Important Notes */}
|
|
||||||
<div className="mt-12 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200">
|
|
||||||
<h4 className="font-medium text-blue-900 mb-4 text-lg">Important Notes:</h4>
|
|
||||||
<ul className="text-sm text-blue-800 space-y-2">
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-blue-600 mr-2">•</span>
|
|
||||||
Theoretical internet speed is the same for all three packages
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-blue-600 mr-2">•</span>
|
|
||||||
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-blue-600 mr-2">•</span>
|
|
||||||
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month
|
|
||||||
+ ¥1,000-3,000 one-time)
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-blue-600 mr-2">•</span>
|
|
||||||
In-home technical assistance available (¥15,000 onsite visiting fee)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Plans Available</h3>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
We couldn't find any internet plans available for your location at this time.
|
|
||||||
</p>
|
|
||||||
<AnimatedButton href="/catalog" className="flex items-center">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InternetPlanCard({
|
|
||||||
plan,
|
|
||||||
installations,
|
|
||||||
}: {
|
|
||||||
plan: InternetPlan;
|
|
||||||
installations: InternetInstallation[];
|
|
||||||
}) {
|
|
||||||
const tier = plan.internetPlanTier;
|
|
||||||
const isGold = tier === "Gold";
|
|
||||||
const isPlatinum = tier === "Platinum";
|
|
||||||
const isSilver = tier === "Silver";
|
|
||||||
|
|
||||||
// Use default variant for all cards to avoid green background on gold
|
|
||||||
const cardVariant = "default";
|
|
||||||
|
|
||||||
// Custom border colors for each tier
|
|
||||||
const getBorderClass = () => {
|
|
||||||
if (isGold) return "border-2 border-yellow-400 shadow-lg hover:shadow-xl";
|
|
||||||
if (isPlatinum) return "border-2 border-indigo-400 shadow-lg hover:shadow-xl";
|
|
||||||
if (isSilver) return "border-2 border-gray-300 shadow-lg hover:shadow-xl";
|
|
||||||
return "border border-gray-200 shadow-lg hover:shadow-xl";
|
|
||||||
};
|
|
||||||
|
|
||||||
const installationPrices = installations
|
|
||||||
.map(inst =>
|
|
||||||
inst.billingCycle === "Monthly"
|
|
||||||
? inst.monthlyPrice ?? inst.unitPrice
|
|
||||||
: inst.oneTimePrice ?? inst.unitPrice
|
|
||||||
)
|
|
||||||
.filter((price): price is number => typeof price === "number" && Number.isFinite(price));
|
|
||||||
|
|
||||||
const minInstallationPrice = installationPrices.length > 0 ? Math.min(...installationPrices) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatedCard
|
|
||||||
variant={cardVariant}
|
|
||||||
className={`overflow-hidden flex flex-col h-full ${getBorderClass()}`}
|
|
||||||
>
|
|
||||||
<div className="p-6 flex flex-col flex-grow">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium border ${
|
|
||||||
isGold
|
|
||||||
? "bg-yellow-100 text-yellow-800 border-yellow-300"
|
|
||||||
: isPlatinum
|
|
||||||
? "bg-purple-100 text-purple-800 border-purple-300"
|
|
||||||
: "bg-gray-100 text-gray-800 border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tier || "Plan"}
|
|
||||||
</span>
|
|
||||||
{isGold && (
|
|
||||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
|
||||||
Recommended
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{plan.monthlyPrice && (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="flex items-baseline justify-end gap-1 text-2xl font-bold text-gray-900">
|
|
||||||
<CurrencyYenIcon className="h-6 w-6" />
|
|
||||||
<span>{plan.monthlyPrice.toLocaleString()}</span>
|
|
||||||
<span className="text-sm text-gray-500 font-normal whitespace-nowrap">
|
|
||||||
per month
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Details */}
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
{plan.catalogMetadata.tierDescription || plan.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Your Plan Includes */}
|
|
||||||
<div className="mb-6 flex-grow">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
|
|
||||||
<ul className="space-y-2 text-sm text-gray-700">
|
|
||||||
{plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? (
|
|
||||||
plan.catalogMetadata.features.map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-start">
|
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
|
||||||
{feature}
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-green-600 mr-2">✓</span>1 NTT Optical Fiber (Flet's
|
|
||||||
Hikari Next - {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
|
|
||||||
{plan.internetOfferingType?.includes("10G")
|
|
||||||
? "10Gbps"
|
|
||||||
: plan.internetOfferingType?.includes("100M")
|
|
||||||
? "100Mbps"
|
|
||||||
: "1Gbps"}
|
|
||||||
) Installation + Monthly
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
|
||||||
Monthly: ¥{plan.monthlyPrice?.toLocaleString()}
|
|
||||||
{minInstallationPrice !== null && (
|
|
||||||
<span className="text-gray-600 text-sm ml-2">
|
|
||||||
(+ installation from ¥
|
|
||||||
{minInstallationPrice.toLocaleString()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
|
||||||
<AnimatedButton
|
|
||||||
href={`/catalog/internet/configure?plan=${plan.sku}`}
|
|
||||||
className="w-full group"
|
|
||||||
>
|
|
||||||
<span>Configure Plan</span>
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform duration-300" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,224 +1,5 @@
|
|||||||
"use client";
|
import CatalogHomeView from "@/features/catalog/views/CatalogHome";
|
||||||
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import {
|
|
||||||
Squares2X2Icon,
|
|
||||||
ServerIcon,
|
|
||||||
DevicePhoneMobileIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
WifiIcon,
|
|
||||||
GlobeAltIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
|
||||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
|
||||||
|
|
||||||
export default function CatalogPage() {
|
export default function CatalogPage() {
|
||||||
return (
|
return <CatalogHomeView />;
|
||||||
<PageLayout icon={<></>} title="" description="">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-700 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
|
||||||
<Squares2X2Icon className="h-4 w-4" />
|
|
||||||
Services Catalog
|
|
||||||
</div>
|
|
||||||
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
|
||||||
Choose Your Perfect
|
|
||||||
<br />
|
|
||||||
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
|
||||||
Connectivity Solution
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
|
|
||||||
Discover high-speed internet, wide range of mobile data options, and secure VPN
|
|
||||||
services. Each solution is personalized based on your location and account eligibility.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Cards */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16">
|
|
||||||
{/* Internet Service */}
|
|
||||||
<ServiceHeroCard
|
|
||||||
title="Internet Service"
|
|
||||||
description="Ultra-high-speed fiber internet with speeds up to 10Gbps. Perfect for homes and apartments with flexible installation options."
|
|
||||||
icon={<ServerIcon className="h-12 w-12" />}
|
|
||||||
features={[
|
|
||||||
"Up to 10Gbps speeds",
|
|
||||||
"Fiber optic technology",
|
|
||||||
"Multiple access modes",
|
|
||||||
"Professional installation",
|
|
||||||
]}
|
|
||||||
href="/catalog/internet"
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* SIM/eSIM Service */}
|
|
||||||
<ServiceHeroCard
|
|
||||||
title="SIM & eSIM"
|
|
||||||
description="Wide range of data options and voice plans with both physical SIM and eSIM options. Family discounts available."
|
|
||||||
icon={<DevicePhoneMobileIcon className="h-12 w-12" />}
|
|
||||||
features={[
|
|
||||||
"Physical SIM & eSIM",
|
|
||||||
"Data + SMS/Voice plans",
|
|
||||||
"Family discounts",
|
|
||||||
"Multiple data options",
|
|
||||||
]}
|
|
||||||
href="/catalog/sim"
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* VPN Service */}
|
|
||||||
<ServiceHeroCard
|
|
||||||
title="VPN Service"
|
|
||||||
description="Secure remote access solutions for business and personal use. Multiple server locations available."
|
|
||||||
icon={<ShieldCheckIcon className="h-12 w-12" />}
|
|
||||||
features={[
|
|
||||||
"Secure encryption",
|
|
||||||
"Multiple locations",
|
|
||||||
"Business & personal",
|
|
||||||
"24/7 connectivity",
|
|
||||||
]}
|
|
||||||
href="/catalog/vpn"
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Info Section */}
|
|
||||||
<div className="bg-gradient-to-br from-gray-50 to-blue-50 rounded-3xl p-10 border border-gray-100">
|
|
||||||
<div className="text-center mb-10">
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Why Choose Our Services?</h2>
|
|
||||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
|
|
||||||
We provide personalized service recommendations based on your location and needs,
|
|
||||||
ensuring you get the best connectivity solution for your situation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<FeatureCard
|
|
||||||
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
|
|
||||||
title="Location-Based Plans"
|
|
||||||
description="Internet plans tailored to your house type and available infrastructure"
|
|
||||||
/>
|
|
||||||
<FeatureCard
|
|
||||||
icon={<GlobeAltIcon className="h-10 w-10 text-purple-600" />}
|
|
||||||
title="Seamless Integration"
|
|
||||||
description="All services work together and can be managed from your single account"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceHeroCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
features,
|
|
||||||
href,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
features: string[];
|
|
||||||
href: string;
|
|
||||||
color: "blue" | "green" | "purple";
|
|
||||||
}) {
|
|
||||||
const colorClasses = {
|
|
||||||
blue: {
|
|
||||||
bg: "bg-blue-50",
|
|
||||||
border: "border-blue-200",
|
|
||||||
iconBg: "bg-blue-100",
|
|
||||||
iconText: "text-blue-600",
|
|
||||||
button: "bg-blue-600 hover:bg-blue-700",
|
|
||||||
hoverBorder: "hover:border-blue-300",
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
bg: "bg-green-50",
|
|
||||||
border: "border-green-200",
|
|
||||||
iconBg: "bg-green-100",
|
|
||||||
iconText: "text-green-600",
|
|
||||||
button: "bg-green-600 hover:bg-green-700",
|
|
||||||
hoverBorder: "hover:border-green-300",
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
bg: "bg-purple-50",
|
|
||||||
border: "border-purple-200",
|
|
||||||
iconBg: "bg-purple-100",
|
|
||||||
iconText: "text-purple-600",
|
|
||||||
button: "bg-purple-600 hover:bg-purple-700",
|
|
||||||
hoverBorder: "hover:border-purple-300",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = colorClasses[color];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatedCard className="relative group rounded-3xl overflow-hidden h-full p-0">
|
|
||||||
<div className="p-8 h-full flex flex-col">
|
|
||||||
{/* Icon and Title */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className={`p-4 rounded-xl ${colors.iconBg}`}>
|
|
||||||
<div className={colors.iconText}>{icon}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-bold text-gray-900">{title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-gray-600 mb-6 leading-relaxed">{description}</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<ul className="space-y-3 mb-8 flex-grow">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-center gap-3">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${colors.button.split(" ")[0]}`} />
|
|
||||||
<span className="text-sm text-gray-700">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
|
||||||
<div className="mt-auto relative z-10">
|
|
||||||
<AnimatedButton
|
|
||||||
href={href}
|
|
||||||
className="w-full font-semibold rounded-2xl relative z-10 group"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<span>Explore Plans</span>
|
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Decorative Background */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 ${colors.bg} opacity-0 group-hover:opacity-10 transition-opacity duration-300 pointer-events-none`}
|
|
||||||
/>
|
|
||||||
</AnimatedCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeatureCard({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AnimatedCard className="text-center p-6 rounded-2xl">
|
|
||||||
<div className="flex justify-center mb-6">
|
|
||||||
<div className="p-3 bg-gray-50 rounded-xl">{icon}</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-3">{title}</h3>
|
|
||||||
<p className="text-gray-600 leading-relaxed">{description}</p>
|
|
||||||
</AnimatedCard>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,829 +1,5 @@
|
|||||||
"use client";
|
import SimConfigureContainer from "@/features/catalog/views/SimConfigure";
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from "react";
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import {
|
|
||||||
ArrowLeftIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
DevicePhoneMobileIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { SimPlan, SimActivationFee, SimAddon } from "@/shared/types/catalog.types";
|
|
||||||
|
|
||||||
import { AddonGroup } from "@/components/catalog/addon-group";
|
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
|
||||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
|
||||||
import { StepHeader } from "@/components/catalog/step-header";
|
|
||||||
import { SimTypeSelector } from "@/components/catalog/sim-type-selector";
|
|
||||||
import { ActivationForm } from "@/components/catalog/activation-form";
|
|
||||||
import { MnpForm } from "@/components/catalog/mnp-form";
|
|
||||||
import { ProgressSteps } from "@/components/catalog/progress-steps";
|
|
||||||
|
|
||||||
function SimConfigureContent() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const planId = searchParams.get("plan");
|
|
||||||
|
|
||||||
const [plan, setPlan] = useState<SimPlan | null>(null);
|
|
||||||
const [activationFees, setActivationFees] = useState<SimActivationFee[]>([]);
|
|
||||||
const [addons, setAddons] = useState<SimAddon[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// Configuration state
|
|
||||||
const [simType, setSimType] = useState<"eSIM" | "Physical SIM">("Physical SIM");
|
|
||||||
const [eid, setEid] = useState("");
|
|
||||||
const [selectedAddons, setSelectedAddons] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// MNP/Porting state
|
|
||||||
const [wantsMnp, setWantsMnp] = useState(false);
|
|
||||||
const [mnpData, setMnpData] = useState({
|
|
||||||
reservationNumber: "",
|
|
||||||
expiryDate: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
mvnoAccountNumber: "",
|
|
||||||
portingLastName: "",
|
|
||||||
portingFirstName: "",
|
|
||||||
portingLastNameKatakana: "",
|
|
||||||
portingFirstNameKatakana: "",
|
|
||||||
portingGender: "" as "Male" | "Female" | "Corporate/Other" | "",
|
|
||||||
portingDateOfBirth: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activation date state
|
|
||||||
const [activationType, setActivationType] = useState<"Immediate" | "Scheduled">("Immediate");
|
|
||||||
const [scheduledActivationDate, setScheduledActivationDate] = useState("");
|
|
||||||
|
|
||||||
// Validation state
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// Step management
|
|
||||||
const [currentStep, setCurrentStep] = useState(() => {
|
|
||||||
const stepParam = searchParams.get("step");
|
|
||||||
return stepParam ? parseInt(stepParam, 10) : 1;
|
|
||||||
});
|
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [plans, fees, addonsData] = await Promise.all([
|
|
||||||
authenticatedApi.get<SimPlan[]>("/catalog/sim/plans"),
|
|
||||||
authenticatedApi.get<SimActivationFee[]>("/catalog/sim/activation-fees"),
|
|
||||||
authenticatedApi.get<SimAddon[]>("/catalog/sim/addons"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
const foundPlan = plans.find((p: SimPlan) => p.sku === planId); // Look up by SKU
|
|
||||||
if (foundPlan) {
|
|
||||||
setPlan(foundPlan);
|
|
||||||
setActivationFees(fees);
|
|
||||||
setAddons(addonsData);
|
|
||||||
|
|
||||||
// Restore state from URL parameters
|
|
||||||
const simTypeParam = searchParams.get("simType");
|
|
||||||
if (simTypeParam && (simTypeParam === "eSIM" || simTypeParam === "Physical SIM")) {
|
|
||||||
setSimType(simTypeParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
const eidParam = searchParams.get("eid");
|
|
||||||
if (eidParam) {
|
|
||||||
setEid(eidParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activationTypeParam = searchParams.get("activationType");
|
|
||||||
if (
|
|
||||||
activationTypeParam &&
|
|
||||||
(activationTypeParam === "Immediate" || activationTypeParam === "Scheduled")
|
|
||||||
) {
|
|
||||||
setActivationType(activationTypeParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledAtParam = searchParams.get("scheduledAt");
|
|
||||||
if (scheduledAtParam) {
|
|
||||||
setScheduledActivationDate(scheduledAtParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore selected addons from URL parameters
|
|
||||||
const addonSkuParams = searchParams.getAll("addonSku");
|
|
||||||
if (addonSkuParams.length > 0) {
|
|
||||||
setSelectedAddons(addonSkuParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore MNP data from URL parameters
|
|
||||||
const isMnpParam = searchParams.get("isMnp");
|
|
||||||
if (isMnpParam === "true") {
|
|
||||||
setWantsMnp(true);
|
|
||||||
|
|
||||||
// Restore all MNP fields
|
|
||||||
const reservationNumber = searchParams.get("reservationNumber");
|
|
||||||
const expiryDate = searchParams.get("expiryDate");
|
|
||||||
const phoneNumber = searchParams.get("phoneNumber");
|
|
||||||
const mvnoAccountNumber = searchParams.get("mvnoAccountNumber");
|
|
||||||
const portingLastName = searchParams.get("portingLastName");
|
|
||||||
const portingFirstName = searchParams.get("portingFirstName");
|
|
||||||
const portingLastNameKatakana = searchParams.get("portingLastNameKatakana");
|
|
||||||
const portingFirstNameKatakana = searchParams.get("portingFirstNameKatakana");
|
|
||||||
const portingGender = searchParams.get("portingGender");
|
|
||||||
const portingDateOfBirth = searchParams.get("portingDateOfBirth");
|
|
||||||
|
|
||||||
setMnpData({
|
|
||||||
reservationNumber: reservationNumber || "",
|
|
||||||
expiryDate: expiryDate || "",
|
|
||||||
phoneNumber: phoneNumber || "",
|
|
||||||
mvnoAccountNumber: mvnoAccountNumber || "",
|
|
||||||
portingLastName: portingLastName || "",
|
|
||||||
portingFirstName: portingFirstName || "",
|
|
||||||
portingLastNameKatakana: portingLastNameKatakana || "",
|
|
||||||
portingFirstNameKatakana: portingFirstNameKatakana || "",
|
|
||||||
portingGender:
|
|
||||||
portingGender === "Male" ||
|
|
||||||
portingGender === "Female" ||
|
|
||||||
portingGender === "Corporate/Other"
|
|
||||||
? portingGender
|
|
||||||
: "",
|
|
||||||
portingDateOfBirth: portingDateOfBirth || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch SIM data:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [planId, searchParams]);
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
// eSIM EID validation
|
|
||||||
if (simType === "eSIM" && !eid.trim()) {
|
|
||||||
newErrors.eid = "EID is required for eSIM activation";
|
|
||||||
} else if (simType === "eSIM" && eid.length < 15) {
|
|
||||||
newErrors.eid = "EID must be at least 15 characters";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activation date validation
|
|
||||||
if (activationType === "Scheduled") {
|
|
||||||
if (!scheduledActivationDate) {
|
|
||||||
newErrors.scheduledActivationDate =
|
|
||||||
"Activation date is required when scheduling activation";
|
|
||||||
} else {
|
|
||||||
const selectedDate = new Date(scheduledActivationDate);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0); // Reset time to start of day for comparison
|
|
||||||
|
|
||||||
if (selectedDate < today) {
|
|
||||||
newErrors.scheduledActivationDate = "Activation date cannot be in the past";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't allow activation more than 30 days in the future
|
|
||||||
const maxDate = new Date();
|
|
||||||
maxDate.setDate(maxDate.getDate() + 30);
|
|
||||||
if (selectedDate > maxDate) {
|
|
||||||
newErrors.scheduledActivationDate =
|
|
||||||
"Activation date cannot be more than 30 days in the future";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MNP validation
|
|
||||||
if (wantsMnp) {
|
|
||||||
if (!mnpData.reservationNumber.trim()) {
|
|
||||||
newErrors.reservationNumber = "MNP reservation number is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.phoneNumber.trim()) {
|
|
||||||
newErrors.phoneNumber = "Phone number to port is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.expiryDate) {
|
|
||||||
newErrors.expiryDate = "MNP expiry date is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.portingLastName.trim()) {
|
|
||||||
newErrors.portingLastName = "Last name is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.portingFirstName.trim()) {
|
|
||||||
newErrors.portingFirstName = "First name is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.portingLastNameKatakana.trim()) {
|
|
||||||
newErrors.portingLastNameKatakana = "Last name (Katakana) is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.portingFirstNameKatakana.trim()) {
|
|
||||||
newErrors.portingFirstNameKatakana = "First name (Katakana) is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.portingGender) {
|
|
||||||
newErrors.portingGender = "Gender is required";
|
|
||||||
}
|
|
||||||
if (!mnpData.portingDateOfBirth) {
|
|
||||||
newErrors.portingDateOfBirth = "Date of birth is required";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle addon selection using the reusable component
|
|
||||||
const handleAddonSelection = (newSelectedSkus: string[]) => {
|
|
||||||
setSelectedAddons(newSelectedSkus);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Smooth step transition function - preserves user data
|
|
||||||
const transitionToStep = (nextStep: number) => {
|
|
||||||
setIsTransitioning(true);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCurrentStep(nextStep);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsTransitioning(false);
|
|
||||||
}, 50);
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateTotals = () => {
|
|
||||||
let monthlyTotal = plan?.monthlyPrice || 0;
|
|
||||||
const oneTimeTotal = activationFees[0]?.price || 0;
|
|
||||||
|
|
||||||
// Add selected addons pricing
|
|
||||||
selectedAddons.forEach(addonSku => {
|
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
|
||||||
if (addon && addon.billingCycle === "Monthly") {
|
|
||||||
monthlyTotal += addon.price;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { monthlyTotal, oneTimeTotal };
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmOrder = () => {
|
|
||||||
if (!plan || !validateForm()) return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
type: "sim",
|
|
||||||
plan: plan.sku, // Use SKU instead of Product2 ID
|
|
||||||
simType: simType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (simType === "eSIM" && eid) {
|
|
||||||
params.append("eid", eid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all selected addons
|
|
||||||
selectedAddons.forEach(addonSku => {
|
|
||||||
params.append("addonSku", addonSku);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add activation date data
|
|
||||||
params.append("activationType", activationType);
|
|
||||||
if (activationType === "Scheduled" && scheduledActivationDate) {
|
|
||||||
params.append("scheduledAt", scheduledActivationDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add MNP data if applicable
|
|
||||||
if (wantsMnp) {
|
|
||||||
params.append("isMnp", "true");
|
|
||||||
Object.entries(mnpData).forEach(([key, value]) => {
|
|
||||||
if (value) {
|
|
||||||
params.append(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/checkout?${params.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Loading..."
|
|
||||||
description="Fetching plan details"
|
|
||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<LoadingSpinner message="Loading plan configuration..." />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Plan Not Found"
|
|
||||||
description="The selected plan could not be found"
|
|
||||||
icon={<ExclamationTriangleIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-red-400 mb-4" />
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Plan Not Found</h2>
|
|
||||||
<p className="text-gray-600 mb-4">The selected plan could not be found</p>
|
|
||||||
<Link href="/catalog/sim" className="text-blue-600 hover:text-blue-800 font-medium">
|
|
||||||
← Return to SIM Plans
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { monthlyTotal, oneTimeTotal } = calculateTotals();
|
|
||||||
|
|
||||||
// Define steps for progress tracking - only show as completed if user has progressed past them
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
number: 1,
|
|
||||||
title: "SIM Type",
|
|
||||||
completed: currentStep > 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: 2,
|
|
||||||
title: "Activation",
|
|
||||||
completed: currentStep > 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: 3,
|
|
||||||
title: "Add-ons",
|
|
||||||
completed: currentStep > 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: 4,
|
|
||||||
title: "Number Porting",
|
|
||||||
completed: currentStep > 4,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title={`Configure ${plan.name}`}
|
|
||||||
description="Customize your mobile service"
|
|
||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<AnimatedButton href="/catalog/sim" variant="outline" size="sm" className="group">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
|
||||||
Back to SIM Plans
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
{/* Selected Plan Summary */}
|
|
||||||
<AnimatedCard variant="static" className="p-6">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
|
||||||
<h3 className="font-bold text-lg text-gray-900">{plan.name}</h3>
|
|
||||||
{plan.simHasFamilyDiscount && (
|
|
||||||
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
|
|
||||||
<UsersIcon className="h-3 w-3" />
|
|
||||||
Family Discount
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
|
|
||||||
<span>
|
|
||||||
<strong>Data:</strong> {plan.simDataSize}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>Type:</strong>{" "}
|
|
||||||
{(plan.simPlanType || "DataSmsVoice") === "DataSmsVoice"
|
|
||||||
? "Data + Voice"
|
|
||||||
: (plan.simPlanType || "DataSmsVoice") === "DataOnly"
|
|
||||||
? "Data Only"
|
|
||||||
: "Voice Only"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
¥{plan.monthlyPrice?.toLocaleString()}/mo
|
|
||||||
</div>
|
|
||||||
{plan.simHasFamilyDiscount && (
|
|
||||||
<div className="text-sm text-green-600 font-medium">Discounted Price</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Progress Steps */}
|
|
||||||
<ProgressSteps steps={steps} currentStep={currentStep} />
|
|
||||||
|
|
||||||
{/* Platinum Warning */}
|
|
||||||
{plan.name.toLowerCase().includes("platinum") && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h5 className="font-medium text-yellow-900">PLATINUM Plan Notice</h5>
|
|
||||||
<p className="text-sm text-yellow-800 mt-1">
|
|
||||||
Additional device subscription fees may apply. Contact support for details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Step 1: SIM Type Selection */}
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={1}
|
|
||||||
title="SIM Type Selection"
|
|
||||||
description="Choose the type of SIM card for your device"
|
|
||||||
/>
|
|
||||||
<SimTypeSelector
|
|
||||||
simType={simType}
|
|
||||||
onSimTypeChange={type => setSimType(type)}
|
|
||||||
eid={eid}
|
|
||||||
onEidChange={setEid}
|
|
||||||
errors={errors}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Continue Button */}
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => {
|
|
||||||
// Validate step 1 before proceeding
|
|
||||||
if (!simType) {
|
|
||||||
setErrors({ simType: "Please select a SIM type" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (simType === "eSIM" && eid.length < 15) {
|
|
||||||
setErrors({ eid: "Please enter a valid EID (15+ characters)" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setErrors({}); // Clear errors
|
|
||||||
transitionToStep(2);
|
|
||||||
}}
|
|
||||||
disabled={!simType || (simType === "eSIM" && eid.length < 15)}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
Continue to Activation
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Activation Date Selection */}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={2}
|
|
||||||
title="Activation Preference"
|
|
||||||
description="Choose when you want your SIM service to be activated"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ActivationForm
|
|
||||||
activationType={activationType}
|
|
||||||
onActivationTypeChange={setActivationType}
|
|
||||||
scheduledActivationDate={scheduledActivationDate}
|
|
||||||
onScheduledActivationDateChange={setScheduledActivationDate}
|
|
||||||
errors={errors}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(1)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to SIM Type
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => {
|
|
||||||
// Validate step 2 before proceeding
|
|
||||||
if (!activationType) {
|
|
||||||
setErrors({ activationType: "Please select an activation type" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activationType === "Scheduled" && !scheduledActivationDate) {
|
|
||||||
setErrors({ scheduledActivationDate: "Please select an activation date" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setErrors({}); // Clear errors
|
|
||||||
transitionToStep(3);
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
!activationType || (activationType === "Scheduled" && !scheduledActivationDate)
|
|
||||||
}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
Continue to Add-ons
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Add-ons */}
|
|
||||||
{currentStep === 3 && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={3}
|
|
||||||
title={(plan.simPlanType || "DataSmsVoice") === "DataOnly" ? "Add-ons" : "Voice Add-ons"}
|
|
||||||
description={
|
|
||||||
(plan.simPlanType || "DataSmsVoice") === "DataOnly"
|
|
||||||
? "No add-ons available for data-only plans"
|
|
||||||
: "Enhance your voice services with these optional features"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addons.length > 0 && (plan.simPlanType || "DataSmsVoice") !== "DataOnly" ? (
|
|
||||||
<AddonGroup
|
|
||||||
addons={addons.map(addon => ({
|
|
||||||
...addon,
|
|
||||||
type: "other" as const,
|
|
||||||
monthlyPrice: addon.billingCycle === "Monthly" ? addon.price : undefined,
|
|
||||||
activationPrice: addon.billingCycle === "Onetime" ? addon.price : undefined,
|
|
||||||
autoAdd: false,
|
|
||||||
requiredWith: [],
|
|
||||||
isBundledAddon: addon.isBundledAddon || false,
|
|
||||||
bundledAddonId: addon.bundledAddonId,
|
|
||||||
displayOrder: addon.displayOrder || 0,
|
|
||||||
}))}
|
|
||||||
selectedAddonSkus={selectedAddons}
|
|
||||||
onAddonToggle={handleAddonSelection}
|
|
||||||
showSkus={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{(plan.simPlanType || "DataSmsVoice") === "DataOnly"
|
|
||||||
? "No add-ons are available for data-only plans."
|
|
||||||
: "No add-ons are available for this plan."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(2)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Activation
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton onClick={() => transitionToStep(4)} className="flex items-center">
|
|
||||||
Continue to Number Porting
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 4: Number Porting (MNP) */}
|
|
||||||
{currentStep === 4 && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={4}
|
|
||||||
title="Number Porting (Optional)"
|
|
||||||
description="Keep your existing phone number by transferring it to your new SIM"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MnpForm
|
|
||||||
wantsMnp={wantsMnp}
|
|
||||||
onWantsMnpChange={setWantsMnp}
|
|
||||||
mnpData={mnpData}
|
|
||||||
onMnpDataChange={setMnpData}
|
|
||||||
errors={errors}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(3)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Add-ons
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => {
|
|
||||||
// Validate MNP form if MNP is selected
|
|
||||||
if (wantsMnp && !validateForm()) {
|
|
||||||
return; // Don't proceed if validation fails
|
|
||||||
}
|
|
||||||
transitionToStep(5);
|
|
||||||
}}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
Review Order
|
|
||||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 5: Review Order */}
|
|
||||||
{currentStep === 5 && (
|
|
||||||
<AnimatedCard
|
|
||||||
variant="static"
|
|
||||||
className={`p-8 transition-all duration-500 ease-in-out transform ${
|
|
||||||
isTransitioning ? "opacity-0 translate-y-4" : "opacity-100 translate-y-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-6">
|
|
||||||
<StepHeader
|
|
||||||
stepNumber={5}
|
|
||||||
title="Review Your Order"
|
|
||||||
description="Review your configuration and proceed to checkout"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Receipt-Style Order Summary */}
|
|
||||||
<div className="max-w-lg mx-auto mb-8 bg-gradient-to-b from-white to-gray-50 shadow-xl rounded-lg border border-gray-200 p-6">
|
|
||||||
{/* Receipt Header */}
|
|
||||||
<div className="text-center border-b-2 border-dashed border-gray-300 pb-4 mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-1">Order Summary</h3>
|
|
||||||
<p className="text-sm text-gray-500">Review your configuration</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Details */}
|
|
||||||
<div className="space-y-3 mb-6">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-gray-900">{plan.name}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{plan.simDataSize}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-semibold text-gray-900">
|
|
||||||
¥{plan.monthlyPrice?.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">per month</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configuration Details */}
|
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Configuration</h4>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">SIM Type:</span>
|
|
||||||
<span className="text-gray-900">{simType || "Not selected"}</span>
|
|
||||||
</div>
|
|
||||||
{simType === "eSIM" && eid && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">EID:</span>
|
|
||||||
<span className="text-gray-900 font-mono text-xs">
|
|
||||||
{eid.substring(0, 12)}...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Activation:</span>
|
|
||||||
<span className="text-gray-900">
|
|
||||||
{activationType === "Scheduled" && scheduledActivationDate
|
|
||||||
? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
|
|
||||||
: activationType || "Not selected"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{wantsMnp && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Number Porting:</span>
|
|
||||||
<span className="text-gray-900">Requested</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add-ons */}
|
|
||||||
{selectedAddons.length > 0 && (
|
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Add-ons</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{selectedAddons.map(addonSku => {
|
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
|
||||||
return (
|
|
||||||
<div key={addonSku} className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">{addon?.name || addonSku}</span>
|
|
||||||
<span className="text-gray-900">
|
|
||||||
¥{addon?.price?.toLocaleString() || 0}
|
|
||||||
<span className="text-xs text-gray-500 ml-1">
|
|
||||||
/{addon?.billingCycle === "Monthly" ? "mo" : "once"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activation Fees */}
|
|
||||||
{activationFees.length > 0 && activationFees.some(fee => fee.price > 0) && (
|
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
|
||||||
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{activationFees.map((fee, index) => (
|
|
||||||
<div key={index} className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">{fee.name}</span>
|
|
||||||
<span className="text-gray-900">¥{fee.price?.toLocaleString() || 0}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-xl font-bold">
|
|
||||||
<span className="text-gray-900">Monthly Total</span>
|
|
||||||
<span className="text-blue-600">¥{monthlyTotal.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
{oneTimeTotal > 0 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">One-time Total</span>
|
|
||||||
<span className="text-orange-600 font-semibold">
|
|
||||||
¥{oneTimeTotal.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Receipt Footer */}
|
|
||||||
<div className="text-center mt-6 pt-4 border-t border-gray-200">
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{(plan.simPlanType || "DataSmsVoice") === "DataOnly" && "Data-only service (no voice features)"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-between items-center pt-6 border-t">
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={() => transitionToStep(4)}
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="px-8 py-4 text-lg"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
|
||||||
Back to Number Porting
|
|
||||||
</AnimatedButton>
|
|
||||||
<AnimatedButton
|
|
||||||
onClick={handleConfirmOrder}
|
|
||||||
size="lg"
|
|
||||||
className="px-12 py-4 text-lg font-semibold"
|
|
||||||
>
|
|
||||||
Proceed to Checkout
|
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SimConfigurePage() {
|
export default function SimConfigurePage() {
|
||||||
return (
|
return <SimConfigureContainer />;
|
||||||
<Suspense fallback={<LoadingSpinner message="Loading SIM configuration..." />}>
|
|
||||||
<SimConfigureContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,506 +1,5 @@
|
|||||||
"use client";
|
import SimPlansView from "@/features/catalog/views/SimPlans";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export default function SimCatalogPage() {
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
return <SimPlansView />;
|
||||||
import {
|
|
||||||
DevicePhoneMobileIcon,
|
|
||||||
CurrencyYenIcon,
|
|
||||||
CheckIcon,
|
|
||||||
InformationCircleIcon,
|
|
||||||
UsersIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
GlobeAltIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
import { SimPlan } from "@/shared/types/catalog.types";
|
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
|
||||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
|
||||||
|
|
||||||
interface PlansByType {
|
|
||||||
DataOnly: SimPlan[];
|
|
||||||
DataSmsVoice: SimPlan[];
|
|
||||||
VoiceOnly: SimPlan[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanTypeSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
plans,
|
|
||||||
showFamilyDiscount,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
plans: SimPlan[];
|
|
||||||
showFamilyDiscount: boolean;
|
|
||||||
}) {
|
|
||||||
if (plans.length === 0) return null;
|
|
||||||
|
|
||||||
// Separate regular and family plans
|
|
||||||
const regularPlans = plans.filter(p => !p.simHasFamilyDiscount);
|
|
||||||
const familyPlans = plans.filter(p => p.simHasFamilyDiscount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in duration-500">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
{icon}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">{title}</h2>
|
|
||||||
<p className="text-gray-600">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Regular Plans */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-6 justify-items-center">
|
|
||||||
{regularPlans.map(plan => (
|
|
||||||
<PlanCard key={plan.id} plan={plan} isFamily={false} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Family Discount Plans */}
|
|
||||||
{showFamilyDiscount && familyPlans.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<UsersIcon className="h-5 w-5 text-green-600" />
|
|
||||||
<h3 className="text-lg font-semibold text-green-900">Family Discount Options</h3>
|
|
||||||
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full">
|
|
||||||
You qualify!
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 justify-items-center">
|
|
||||||
{familyPlans.map(plan => (
|
|
||||||
<PlanCard key={plan.id} plan={plan} isFamily={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
|
|
||||||
return (
|
|
||||||
<AnimatedCard variant={isFamily ? "success" : "default"} className="p-6 w-full max-w-sm">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<DevicePhoneMobileIcon className="h-4 w-4 text-blue-600" />
|
|
||||||
<span className="font-bold text-sm text-gray-900">{plan.simDataSize}</span>
|
|
||||||
</div>
|
|
||||||
{isFamily && (
|
|
||||||
<div className="flex items-center gap-1 mb-1">
|
|
||||||
<UsersIcon className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium">
|
|
||||||
Family
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<CurrencyYenIcon className="h-4 w-4 text-gray-600" />
|
|
||||||
<span className="text-xl font-bold text-gray-900">
|
|
||||||
{plan.monthlyPrice?.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-600 text-sm">/month</span>
|
|
||||||
</div>
|
|
||||||
{isFamily && (
|
|
||||||
<div className="text-xs text-green-600 font-medium mt-1">Discounted price</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-xs text-gray-600 line-clamp-2">{plan.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatedButton href={`/catalog/sim/configure?plan=${plan.sku}`} className="w-full" size="sm">
|
|
||||||
Configure
|
|
||||||
</AnimatedButton>
|
|
||||||
</AnimatedCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SimPlansPage() {
|
|
||||||
const [plans, setPlans] = useState<SimPlan[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [hasExistingSim, setHasExistingSim] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
|
|
||||||
"data-voice"
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const plansData = await authenticatedApi.get<SimPlan[]>("/catalog/sim/plans");
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setPlans(plansData);
|
|
||||||
// Check if any plans have family discount (indicates user has existing SIM)
|
|
||||||
setHasExistingSim(plansData.some(p => p.simHasFamilyDiscount));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load SIM plans");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="SIM Plans"
|
|
||||||
description="Loading plans..."
|
|
||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<LoadingSpinner message="Loading SIM plans..." />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="SIM Plans"
|
|
||||||
description="Error loading plans"
|
|
||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="rounded-lg bg-red-50 border border-red-200 p-6">
|
|
||||||
<div className="text-red-800 font-medium">Failed to load SIM plans</div>
|
|
||||||
<div className="text-red-600 text-sm mt-1">{error}</div>
|
|
||||||
<AnimatedButton href="/catalog" className="flex items-center mt-4">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group plans by type
|
|
||||||
const plansByType: PlansByType = plans.reduce(
|
|
||||||
(acc, plan) => {
|
|
||||||
const type = plan.simPlanType || "DataSmsVoice";
|
|
||||||
acc[type].push(plan);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
DataOnly: [] as SimPlan[],
|
|
||||||
DataSmsVoice: [] as SimPlan[],
|
|
||||||
VoiceOnly: [] as SimPlan[],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="SIM Plans"
|
|
||||||
description="Choose your mobile plan with flexible options"
|
|
||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="mb-6 flex justify-center">
|
|
||||||
<AnimatedButton href="/catalog" variant="outline" size="sm" className="group">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your SIM Plan</h1>
|
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
|
||||||
Wide range of data options and voice plans with both physical SIM and eSIM options.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* Family Discount Banner */}
|
|
||||||
{hasExistingSim && (
|
|
||||||
<div className="mb-8 p-6 rounded-xl border-2 border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<UsersIcon className="h-8 w-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-bold text-green-900 text-lg mb-2">
|
|
||||||
🎉 Family Discount Available!
|
|
||||||
</h3>
|
|
||||||
<p className="text-green-800 mb-3">
|
|
||||||
You have existing SIM services, so you qualify for family discount pricing on
|
|
||||||
additional lines.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-green-700">Reduced monthly pricing</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-green-700">Same great features</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-green-700">Easy to manage</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="mb-8 flex justify-center">
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("data-voice")}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
|
|
||||||
activeTab === "data-voice"
|
|
||||||
? "border-blue-500 text-blue-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<PhoneIcon
|
|
||||||
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`}
|
|
||||||
/>
|
|
||||||
Data + SMS/Voice
|
|
||||||
{plansByType.DataSmsVoice.length > 0 && (
|
|
||||||
<span
|
|
||||||
className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
|
||||||
activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plansByType.DataSmsVoice.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("data-only")}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
|
|
||||||
activeTab === "data-only"
|
|
||||||
? "border-purple-500 text-purple-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<GlobeAltIcon
|
|
||||||
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-only" ? "scale-110" : ""}`}
|
|
||||||
/>
|
|
||||||
Data Only
|
|
||||||
{plansByType.DataOnly.length > 0 && (
|
|
||||||
<span
|
|
||||||
className={`bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
|
||||||
activeTab === "data-only" ? "scale-110 bg-purple-200" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plansByType.DataOnly.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("voice-only")}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
|
|
||||||
activeTab === "voice-only"
|
|
||||||
? "border-orange-500 text-orange-600"
|
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<PhoneIcon
|
|
||||||
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`}
|
|
||||||
/>
|
|
||||||
Voice Only
|
|
||||||
{plansByType.VoiceOnly.length > 0 && (
|
|
||||||
<span
|
|
||||||
className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
|
||||||
activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plansByType.VoiceOnly.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="min-h-[400px] relative">
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-500 ease-in-out ${
|
|
||||||
activeTab === "data-voice"
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{activeTab === "data-voice" && (
|
|
||||||
<PlanTypeSection
|
|
||||||
title="Data + SMS/Voice Plans"
|
|
||||||
description="Internet, calling, and SMS included"
|
|
||||||
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
|
|
||||||
plans={plansByType.DataSmsVoice}
|
|
||||||
showFamilyDiscount={hasExistingSim}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-500 ease-in-out ${
|
|
||||||
activeTab === "data-only"
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{activeTab === "data-only" && (
|
|
||||||
<PlanTypeSection
|
|
||||||
title="Data Only Plans"
|
|
||||||
description="Internet access for tablets, laptops, and IoT devices"
|
|
||||||
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
|
|
||||||
plans={plansByType.DataOnly}
|
|
||||||
showFamilyDiscount={hasExistingSim}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-500 ease-in-out ${
|
|
||||||
activeTab === "voice-only"
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{activeTab === "voice-only" && (
|
|
||||||
<PlanTypeSection
|
|
||||||
title="Voice Only Plans"
|
|
||||||
description="Traditional calling and SMS without internet"
|
|
||||||
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
|
|
||||||
plans={plansByType.VoiceOnly}
|
|
||||||
showFamilyDiscount={hasExistingSim}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto">
|
|
||||||
<h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
|
|
||||||
Plan Features & Terms
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-sm">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">3-Month Contract</div>
|
|
||||||
<div className="text-gray-600">Minimum 3 billing months</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">First Month Free</div>
|
|
||||||
<div className="text-gray-600">Basic fee waived initially</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">5G Network</div>
|
|
||||||
<div className="text-gray-600">High-speed coverage</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">eSIM Support</div>
|
|
||||||
<div className="text-gray-600">Digital activation</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">Family Discounts</div>
|
|
||||||
<div className="text-gray-600">Multi-line savings</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">Plan Switching</div>
|
|
||||||
<div className="text-gray-600">Free data plan changes</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Section */}
|
|
||||||
<div className="mt-8 p-6 rounded-lg border border-blue-200 bg-blue-50 max-w-4xl mx-auto">
|
|
||||||
<div className="flex items-start gap-3 mb-4">
|
|
||||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium text-blue-900 mb-2">Important Terms & Conditions</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">Contract Period</div>
|
|
||||||
<p className="text-blue-800">
|
|
||||||
Minimum 3 full billing months required. First month (sign-up to end of month) is
|
|
||||||
free and doesn't count toward contract.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">Billing Cycle</div>
|
|
||||||
<p className="text-blue-800">
|
|
||||||
Monthly billing from 1st to end of month. Regular billing starts on 1st of
|
|
||||||
following month after sign-up.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">Cancellation</div>
|
|
||||||
<p className="text-blue-800">
|
|
||||||
Can be requested online after 3rd month. Service terminates at end of billing
|
|
||||||
cycle.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">Plan Changes</div>
|
|
||||||
<p className="text-blue-800">
|
|
||||||
Data plan switching is free and takes effect next month. Voice plan changes
|
|
||||||
require new SIM and cancellation policies apply.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">Calling/SMS Charges</div>
|
|
||||||
<p className="text-blue-800">
|
|
||||||
Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing
|
|
||||||
cycle.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900">SIM Replacement</div>
|
|
||||||
<p className="text-blue-800">
|
|
||||||
Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,207 +1,5 @@
|
|||||||
"use client";
|
import VpnPlansView from "@/features/catalog/views/VpnPlans";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export default function VpnCatalogPage() {
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
return <VpnPlansView />;
|
||||||
import { ShieldCheckIcon, CurrencyYenIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
import { VpnPlan, VpnActivationFee } from "@/shared/types/catalog.types";
|
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
|
||||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
|
||||||
|
|
||||||
export default function VpnPlansPage() {
|
|
||||||
const [vpnPlans, setVpnPlans] = useState<VpnPlan[]>([]);
|
|
||||||
const [activationFees, setActivationFees] = useState<VpnActivationFee[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const [plans, fees] = await Promise.all([
|
|
||||||
authenticatedApi.get<VpnPlan[]>("/catalog/vpn/plans"),
|
|
||||||
authenticatedApi.get<VpnActivationFee[]>("/catalog/vpn/activation-fees"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setVpnPlans(plans);
|
|
||||||
setActivationFees(fees);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load VPN plans");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Activation fee info is shown in a generic note; region-specific fee not used currently.
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="VPN Plans"
|
|
||||||
description="Loading plans..."
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<LoadingSpinner message="Loading VPN plans..." />
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="VPN Plans"
|
|
||||||
description="Error loading plans"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="rounded-lg bg-red-50 border border-red-200 p-6">
|
|
||||||
<div className="text-red-800 font-medium">Failed to load VPN plans</div>
|
|
||||||
<div className="text-red-600 text-sm mt-1">{error}</div>
|
|
||||||
<AnimatedButton href="/catalog" className="flex items-center mt-4">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="VPN Router Rental"
|
|
||||||
description="Secure VPN router rental"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<AnimatedButton href="/catalog" variant="outline" size="sm" className="group">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
SonixNet VPN Rental Router Service
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
|
||||||
Fast and secure VPN connection to San Francisco or London for accessing geo-restricted
|
|
||||||
content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* Available Plans Section */}
|
|
||||||
{vpnPlans.length > 0 ? (
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2 text-center">Available Plans</h2>
|
|
||||||
<p className="text-gray-600 text-center mb-6">(One region per router)</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
|
||||||
{vpnPlans.map(plan => {
|
|
||||||
return (
|
|
||||||
<AnimatedCard
|
|
||||||
key={plan.id}
|
|
||||||
className="p-6 border-2 border-blue-200 hover:border-blue-300 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4 text-center">
|
|
||||||
<div className="flex items-baseline justify-center gap-1">
|
|
||||||
<CurrencyYenIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
<span className="text-3xl font-bold text-gray-900">
|
|
||||||
{plan.monthlyPrice?.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-600">/month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VPN plans don't have features defined in the type structure */}
|
|
||||||
|
|
||||||
<AnimatedButton
|
|
||||||
href={`/catalog/vpn/configure?plan=${plan.sku}`}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Configure Plan
|
|
||||||
</AnimatedButton>
|
|
||||||
</AnimatedCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activationFees.length > 0 && (
|
|
||||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg max-w-4xl mx-auto">
|
|
||||||
<p className="text-sm text-blue-800 text-center">
|
|
||||||
A one-time activation fee of 3000 JPY is incurred seprarately for each rental
|
|
||||||
unit. Tax (10%) not included.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<ShieldCheckIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No VPN Plans Available</h3>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
We couldn't find any VPN plans available at this time.
|
|
||||||
</p>
|
|
||||||
<AnimatedButton href="/catalog" className="flex items-center">
|
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
Back to Services
|
|
||||||
</AnimatedButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Service Description Section */}
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-8 mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">How It Works</h2>
|
|
||||||
<div className="space-y-4 text-gray-700">
|
|
||||||
<p>
|
|
||||||
SonixNet VPN is the easiest way to access video streaming services from overseas on
|
|
||||||
your network media players such as an Apple TV, Roku, or Amazon Fire.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
A configured Wi-Fi router is provided for rental (no purchase required, no hidden
|
|
||||||
fees). All you will need to do is to plug the VPN router into your existing internet
|
|
||||||
connection.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Then you can connect your network media players to the VPN Wi-Fi network, to connect
|
|
||||||
to the VPN server.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
For daily Internet usage that does not require a VPN, we recommend connecting to your
|
|
||||||
regular home Wi-Fi.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disclaimer Section */}
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-8">
|
|
||||||
<h3 className="font-bold text-yellow-900 mb-3">Important Disclaimer</h3>
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
*1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service
|
|
||||||
will establish a network connection that virtually locates you in the designated server
|
|
||||||
location, then you will sign up for the streaming services of your choice. Not all
|
|
||||||
services/websites can be unblocked. Assist Solutions does not guarantee or bear any
|
|
||||||
responsibility over the unblocking of any websites or the quality of the
|
|
||||||
streaming/browsing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,552 +1,5 @@
|
|||||||
"use client";
|
import CheckoutContainer from "@/features/checkout/views/CheckoutContainer";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback, Suspense } from "react";
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import {
|
|
||||||
ShieldCheckIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
|
|
||||||
import { usePaymentMethods } from "@/hooks/useInvoices";
|
|
||||||
import { usePaymentRefresh } from "@/hooks/usePaymentRefresh";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
|
||||||
import { InlineToast } from "@/components/ui/inline-toast";
|
|
||||||
|
|
||||||
import {
|
|
||||||
InternetPlan,
|
|
||||||
InternetAddon,
|
|
||||||
InternetInstallation,
|
|
||||||
SimPlan,
|
|
||||||
SimActivationFee,
|
|
||||||
SimAddon,
|
|
||||||
CheckoutState,
|
|
||||||
OrderItem,
|
|
||||||
buildInternetOrderItems,
|
|
||||||
buildSimOrderItems,
|
|
||||||
calculateTotals,
|
|
||||||
buildOrderSKUs,
|
|
||||||
} from "@/shared/types/catalog.types";
|
|
||||||
|
|
||||||
interface Address {
|
|
||||||
street: string | null;
|
|
||||||
streetLine2: string | null;
|
|
||||||
city: string | null;
|
|
||||||
state: string | null;
|
|
||||||
postalCode: string | null;
|
|
||||||
country: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CheckoutContent() {
|
|
||||||
const params = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
|
||||||
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
|
|
||||||
|
|
||||||
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
|
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
orderItems: [],
|
|
||||||
totals: { monthlyTotal: 0, oneTimeTotal: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch payment methods to check if user has payment method on file
|
|
||||||
const {
|
|
||||||
data: paymentMethods,
|
|
||||||
isLoading: paymentMethodsLoading,
|
|
||||||
error: paymentMethodsError,
|
|
||||||
refetch: refetchPaymentMethods,
|
|
||||||
} = usePaymentMethods();
|
|
||||||
|
|
||||||
const paymentRefresh = usePaymentRefresh({
|
|
||||||
refetch: refetchPaymentMethods,
|
|
||||||
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
|
|
||||||
attachFocusListeners: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const orderType = (() => {
|
|
||||||
const type = params.get("type") || "internet";
|
|
||||||
// Map to backend expected values
|
|
||||||
switch (type.toLowerCase()) {
|
|
||||||
case "sim":
|
|
||||||
return "SIM";
|
|
||||||
case "internet":
|
|
||||||
return "Internet";
|
|
||||||
case "vpn":
|
|
||||||
return "VPN";
|
|
||||||
default:
|
|
||||||
return "Other";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const selections = useMemo(() => {
|
|
||||||
const obj: Record<string, string> = {};
|
|
||||||
params.forEach((v, k) => {
|
|
||||||
if (k !== "type") obj[k] = v;
|
|
||||||
});
|
|
||||||
return obj;
|
|
||||||
}, [params]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
setCheckoutState(prev => ({ ...prev, loading: true, error: null }));
|
|
||||||
|
|
||||||
// Validate required parameters
|
|
||||||
if (!selections.plan) {
|
|
||||||
throw new Error("No plan selected. Please go back and select a plan.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let orderItems: OrderItem[] = [];
|
|
||||||
|
|
||||||
if (orderType === "Internet") {
|
|
||||||
// Fetch Internet data
|
|
||||||
const [plans, addons, installations] = await Promise.all([
|
|
||||||
authenticatedApi.get<InternetPlan[]>("/catalog/internet/plans"),
|
|
||||||
authenticatedApi.get<InternetAddon[]>("/catalog/internet/addons"),
|
|
||||||
authenticatedApi.get<InternetInstallation[]>("/catalog/internet/installations"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const plan = plans.find(p => p.sku === selections.plan);
|
|
||||||
if (!plan) {
|
|
||||||
throw new Error(
|
|
||||||
`Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle addon SKUs like SIM flow
|
|
||||||
const addonSkus: string[] = [];
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
urlParams.getAll("addonSku").forEach(sku => {
|
|
||||||
if (sku && !addonSkus.includes(sku)) {
|
|
||||||
addonSkus.push(sku);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
orderItems = buildInternetOrderItems(plan, addons, installations, {
|
|
||||||
installationSku: selections.installationSku,
|
|
||||||
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
|
||||||
});
|
|
||||||
} else if (orderType === "SIM") {
|
|
||||||
// Fetch SIM data
|
|
||||||
const [plans, activationFees, addons] = await Promise.all([
|
|
||||||
authenticatedApi.get<SimPlan[]>("/catalog/sim/plans"),
|
|
||||||
authenticatedApi.get<SimActivationFee[]>("/catalog/sim/activation-fees"),
|
|
||||||
authenticatedApi.get<SimAddon[]>("/catalog/sim/addons"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const plan = plans.find(p => p.sku === selections.plan); // Look up by SKU instead of ID
|
|
||||||
if (!plan) {
|
|
||||||
throw new Error(
|
|
||||||
`SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Handle multiple addons from URL parameters
|
|
||||||
const addonSkus: string[] = [];
|
|
||||||
if (selections.addonSku) {
|
|
||||||
// Single addon (legacy support)
|
|
||||||
addonSkus.push(selections.addonSku);
|
|
||||||
}
|
|
||||||
// Check for multiple addonSku parameters
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
urlParams.getAll("addonSku").forEach(sku => {
|
|
||||||
if (sku && !addonSkus.includes(sku)) {
|
|
||||||
addonSkus.push(sku);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
orderItems = buildSimOrderItems(plan, activationFees, addons, {
|
|
||||||
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
const totals = calculateTotals(orderItems);
|
|
||||||
setCheckoutState(prev => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
orderItems,
|
|
||||||
totals,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (mounted) {
|
|
||||||
setCheckoutState(prev => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error: error instanceof Error ? error.message : "Failed to load checkout data",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [orderType, selections]);
|
|
||||||
|
|
||||||
const handleSubmitOrder = async () => {
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
const skus = buildOrderSKUs(checkoutState.orderItems);
|
|
||||||
|
|
||||||
// Validate we have SKUs before proceeding
|
|
||||||
if (!skus || skus.length === 0) {
|
|
||||||
throw new Error("No products selected for order. Please go back and select products.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send SKUs + configurations - backend resolves product data from SKUs,
|
|
||||||
// uses configurations for fields that cannot be inferred
|
|
||||||
const configurations: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
// Extract configurations from URL params (these come from configure pages)
|
|
||||||
if (selections.accessMode) configurations.accessMode = selections.accessMode;
|
|
||||||
if (selections.simType) configurations.simType = selections.simType;
|
|
||||||
if (selections.eid) configurations.eid = selections.eid;
|
|
||||||
// VPN region is inferred from product VPN_Region__c field, no configuration needed
|
|
||||||
if (selections.activationType) configurations.activationType = selections.activationType;
|
|
||||||
if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt;
|
|
||||||
|
|
||||||
// MNP fields (must match backend field expectations exactly)
|
|
||||||
if (selections.isMnp) configurations.isMnp = selections.isMnp;
|
|
||||||
if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber;
|
|
||||||
if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate;
|
|
||||||
if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber;
|
|
||||||
if (selections.mvnoAccountNumber)
|
|
||||||
configurations.mvnoAccountNumber = selections.mvnoAccountNumber;
|
|
||||||
if (selections.portingLastName) configurations.portingLastName = selections.portingLastName;
|
|
||||||
if (selections.portingFirstName)
|
|
||||||
configurations.portingFirstName = selections.portingFirstName;
|
|
||||||
if (selections.portingLastNameKatakana)
|
|
||||||
configurations.portingLastNameKatakana = selections.portingLastNameKatakana;
|
|
||||||
if (selections.portingFirstNameKatakana)
|
|
||||||
configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana;
|
|
||||||
if (selections.portingGender) configurations.portingGender = selections.portingGender;
|
|
||||||
if (selections.portingDateOfBirth)
|
|
||||||
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
|
||||||
|
|
||||||
// Include address in configurations if it was updated during checkout
|
|
||||||
if (confirmedAddress) {
|
|
||||||
configurations.address = confirmedAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderData = {
|
|
||||||
orderType,
|
|
||||||
skus: skus,
|
|
||||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required SIM fields for all order types
|
|
||||||
if (orderType === "SIM") {
|
|
||||||
if (!selections.eid && selections.simType === "eSIM") {
|
|
||||||
throw new Error("EID is required for eSIM activation. Please go back and provide your EID.");
|
|
||||||
}
|
|
||||||
if (!selections.phoneNumber && !selections.mnpPhone) {
|
|
||||||
throw new Error("Phone number is required for SIM activation. Please go back and provide a phone number.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
|
||||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Order submission failed:", error);
|
|
||||||
|
|
||||||
let errorMessage = "Order submission failed";
|
|
||||||
if (error instanceof Error) {
|
|
||||||
errorMessage = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCheckoutState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: errorMessage,
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddressConfirmed = useCallback((address?: Address) => {
|
|
||||||
logger.info("Address confirmed in checkout", { address });
|
|
||||||
setAddressConfirmed(true);
|
|
||||||
setConfirmedAddress(address || null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddressIncomplete = useCallback(() => {
|
|
||||||
logger.info("Address marked as incomplete in checkout");
|
|
||||||
setAddressConfirmed(false);
|
|
||||||
setConfirmedAddress(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (checkoutState.loading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Submit Order"
|
|
||||||
description="Loading order details"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="text-center py-12">Loading order submission...</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkoutState.error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Submit Order"
|
|
||||||
description="Error loading order submission"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-red-600 mb-4">{checkoutState.error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Go Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title="Checkout"
|
|
||||||
description="Verify your address, review totals, and submit your order"
|
|
||||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
||||||
>
|
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
|
||||||
<InlineToast
|
|
||||||
visible={paymentRefresh.toast.visible}
|
|
||||||
text={paymentRefresh.toast.text}
|
|
||||||
tone={paymentRefresh.toast.tone}
|
|
||||||
/>
|
|
||||||
{/* Confirm Details - single card with Address + Payment */}
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-2xl p-6 md:p-7 shadow-sm">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<ShieldCheckIcon className="w-6 h-6 text-blue-600" />
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Confirm Details</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Sub-card: Installation Address */}
|
|
||||||
<SubCard>
|
|
||||||
<AddressConfirmation
|
|
||||||
embedded
|
|
||||||
onAddressConfirmed={handleAddressConfirmed}
|
|
||||||
onAddressIncomplete={handleAddressIncomplete}
|
|
||||||
orderType={orderType}
|
|
||||||
/>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
{/* Sub-card: Billing & Payment */}
|
|
||||||
<SubCard
|
|
||||||
title="Billing & Payment"
|
|
||||||
icon={<CreditCardIcon className="w-5 h-5 text-blue-600" />}
|
|
||||||
right={
|
|
||||||
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
|
||||||
<StatusPill label="Verified" variant="success" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{paymentMethodsLoading ? (
|
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
|
||||||
Checking payment methods...
|
|
||||||
</div>
|
|
||||||
) : paymentMethodsError ? (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-amber-800 text-sm font-medium">
|
|
||||||
Unable to verify payment methods
|
|
||||||
</p>
|
|
||||||
<p className="text-amber-700 text-sm mt-1">
|
|
||||||
If you just added a payment method, try refreshing.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void paymentRefresh.triggerRefresh();
|
|
||||||
}}
|
|
||||||
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
|
|
||||||
>
|
|
||||||
Check Again
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/billing/payments")}
|
|
||||||
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Add Payment Method
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
|
||||||
<p className="text-sm text-green-700">
|
|
||||||
Payment will be processed using your card on file after approval.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
|
|
||||||
<p className="text-red-700 text-sm mt-1">
|
|
||||||
Add a payment method to submit your order.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void paymentRefresh.triggerRefresh();
|
|
||||||
}}
|
|
||||||
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
|
|
||||||
>
|
|
||||||
Check Again
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/billing/payments")}
|
|
||||||
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Add Payment Method
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review & Submit - prominent card with guidance */}
|
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl p-6 md:p-7 text-center">
|
|
||||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm">
|
|
||||||
<ShieldCheckIcon className="w-8 h-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
|
|
||||||
<p className="text-gray-700 mb-4 max-w-xl mx-auto">
|
|
||||||
You’re almost done. Confirm your details above, then submit your order. We’ll review and
|
|
||||||
notify you when everything is ready.
|
|
||||||
</p>
|
|
||||||
<div className="bg-white rounded-lg p-4 border border-blue-200 text-left max-w-2xl mx-auto">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">What to expect</h3>
|
|
||||||
<div className="text-sm text-gray-700 space-y-1">
|
|
||||||
<p>• Our team reviews your order and schedules setup if needed</p>
|
|
||||||
<p>• We may contact you to confirm details or availability</p>
|
|
||||||
<p>• We only charge your card after the order is approved</p>
|
|
||||||
<p>• You’ll receive confirmation and next steps by email</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Totals Summary */}
|
|
||||||
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200 max-w-2xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-medium text-gray-700">Estimated Total</span>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-bold text-gray-900">
|
|
||||||
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
|
|
||||||
</div>
|
|
||||||
{checkoutState.totals.oneTimeTotal > 0 && (
|
|
||||||
<div className="text-sm text-orange-600 font-medium">
|
|
||||||
+ ¥{checkoutState.totals.oneTimeTotal.toLocaleString()} one-time
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
// Construct the configure URL with current parameters to preserve data
|
|
||||||
// Add step parameter to go directly to review step
|
|
||||||
const urlParams = new URLSearchParams(params.toString());
|
|
||||||
const reviewStep = orderType === "Internet" ? "4" : "5";
|
|
||||||
urlParams.set("step", reviewStep);
|
|
||||||
|
|
||||||
const configureUrl =
|
|
||||||
orderType === "Internet"
|
|
||||||
? `/catalog/internet/configure?${urlParams.toString()}`
|
|
||||||
: `/catalog/sim/configure?${urlParams.toString()}`;
|
|
||||||
router.push(configureUrl);
|
|
||||||
}}
|
|
||||||
className="flex-1 px-6 py-4 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
← Back to Review
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSubmitOrder()}
|
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
checkoutState.orderItems.length === 0 ||
|
|
||||||
!addressConfirmed ||
|
|
||||||
paymentMethodsLoading ||
|
|
||||||
!paymentMethods ||
|
|
||||||
paymentMethods.paymentMethods.length === 0
|
|
||||||
}
|
|
||||||
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Submitting Order...
|
|
||||||
</span>
|
|
||||||
) : !addressConfirmed ? (
|
|
||||||
"📍 Confirm Installation Address"
|
|
||||||
) : paymentMethodsLoading ? (
|
|
||||||
"⏳ Verifying Payment Method..."
|
|
||||||
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
|
|
||||||
"💳 Add Payment Method to Continue"
|
|
||||||
) : (
|
|
||||||
"📋 Submit Order"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CheckoutPage() {
|
export default function CheckoutPage() {
|
||||||
return (
|
return <CheckoutContainer />;
|
||||||
<Suspense fallback={<div className="text-center py-12">Loading checkout...</div>}>
|
|
||||||
<CheckoutContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,517 +1,5 @@
|
|||||||
"use client";
|
import OrderDetailContainer from "@/features/orders/views/OrderDetail";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
export default function OrderDetailPage() {
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
return <OrderDetailContainer />;
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import {
|
|
||||||
ClipboardDocumentCheckIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
WifiIcon,
|
|
||||||
DevicePhoneMobileIcon,
|
|
||||||
LockClosedIcon,
|
|
||||||
CubeIcon,
|
|
||||||
StarIcon,
|
|
||||||
WrenchScrewdriverIcon,
|
|
||||||
PlusIcon,
|
|
||||||
BoltIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
quantity: number;
|
|
||||||
unitPrice: number;
|
|
||||||
totalPrice: number;
|
|
||||||
product: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
sku: string;
|
|
||||||
whmcsProductId?: string;
|
|
||||||
itemClass: string;
|
|
||||||
billingCycle: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusInfo {
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
bgColor: string;
|
|
||||||
description: string;
|
|
||||||
nextAction?: string;
|
|
||||||
timeline?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderSummary {
|
|
||||||
id: string;
|
|
||||||
orderNumber?: string;
|
|
||||||
status: string;
|
|
||||||
orderType?: string;
|
|
||||||
effectiveDate?: string;
|
|
||||||
totalAmount?: number;
|
|
||||||
accountName?: string;
|
|
||||||
createdDate: string;
|
|
||||||
lastModifiedDate: string;
|
|
||||||
activationType?: string;
|
|
||||||
activationStatus?: string;
|
|
||||||
scheduledAt?: string;
|
|
||||||
whmcsOrderId?: string;
|
|
||||||
items?: OrderItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDetailedStatusInfo = (
|
|
||||||
status: string,
|
|
||||||
activationStatus?: string,
|
|
||||||
activationType?: string,
|
|
||||||
scheduledAt?: string
|
|
||||||
): StatusInfo => {
|
|
||||||
if (activationStatus === "Activated") {
|
|
||||||
return {
|
|
||||||
label: "Service Active",
|
|
||||||
color: "text-green-800",
|
|
||||||
bgColor: "bg-green-50 border-green-200",
|
|
||||||
description: "Your service is active and ready to use",
|
|
||||||
timeline: "Service activated successfully",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "Draft" || status === "Pending Review") {
|
|
||||||
return {
|
|
||||||
label: "Under Review",
|
|
||||||
color: "text-blue-800",
|
|
||||||
bgColor: "bg-blue-50 border-blue-200",
|
|
||||||
description: "Our team is reviewing your order details",
|
|
||||||
nextAction: "We will contact you within 1 business day with next steps",
|
|
||||||
timeline: "Review typically takes 1 business day",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activationStatus === "Scheduled") {
|
|
||||||
const scheduledDate = scheduledAt
|
|
||||||
? new Date(scheduledAt).toLocaleDateString("en-US", {
|
|
||||||
weekday: "long",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
: "soon";
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: "Installation Scheduled",
|
|
||||||
color: "text-orange-800",
|
|
||||||
bgColor: "bg-orange-50 border-orange-200",
|
|
||||||
description: "Your installation has been scheduled",
|
|
||||||
nextAction: `Installation scheduled for ${scheduledDate}`,
|
|
||||||
timeline: "Please be available during the scheduled time",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activationStatus === "Activating") {
|
|
||||||
return {
|
|
||||||
label: "Setting Up Service",
|
|
||||||
color: "text-purple-800",
|
|
||||||
bgColor: "bg-purple-50 border-purple-200",
|
|
||||||
description: "We're configuring your service",
|
|
||||||
nextAction: "Installation team will contact you to schedule",
|
|
||||||
timeline: "Setup typically takes 3-5 business days",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: status || "Processing",
|
|
||||||
color: "text-gray-800",
|
|
||||||
bgColor: "bg-gray-50 border-gray-200",
|
|
||||||
description: "Your order is being processed",
|
|
||||||
timeline: "We will update you as progress is made",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getServiceTypeIcon = (orderType?: string) => {
|
|
||||||
switch (orderType) {
|
|
||||||
case "Internet":
|
|
||||||
return <WifiIcon className="h-6 w-6" />;
|
|
||||||
case "SIM":
|
|
||||||
return <DevicePhoneMobileIcon className="h-6 w-6" />;
|
|
||||||
case "VPN":
|
|
||||||
return <LockClosedIcon className="h-6 w-6" />;
|
|
||||||
default:
|
|
||||||
return <CubeIcon className="h-6 w-6" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateDetailedTotals = (items: OrderItem[]) => {
|
|
||||||
let monthlyTotal = 0;
|
|
||||||
let oneTimeTotal = 0;
|
|
||||||
|
|
||||||
items.forEach(item => {
|
|
||||||
if (item.product.billingCycle === "Monthly") {
|
|
||||||
monthlyTotal += item.totalPrice || 0;
|
|
||||||
} else {
|
|
||||||
oneTimeTotal += item.totalPrice || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { monthlyTotal, oneTimeTotal };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrderStatusPage() {
|
|
||||||
const params = useParams<{ id: string }>();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [data, setData] = useState<OrderSummary | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const isNewOrder = searchParams.get("status") === "success";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
try {
|
|
||||||
const res = await authenticatedApi.get<OrderSummary>(`/orders/${params.id}`);
|
|
||||||
if (mounted) setData(res);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setError(e instanceof Error ? e.message : "Failed to load order");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void fetchStatus();
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
void fetchStatus();
|
|
||||||
}, 5000);
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [params.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ClipboardDocumentCheckIcon />}
|
|
||||||
title={data ? `${data.orderType} Service Order` : "Order Details"}
|
|
||||||
description={
|
|
||||||
data ? `Order #${data.orderNumber || data.id.slice(-8)}` : "Loading order details..."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && <div className="text-red-600 text-sm mb-4">{error}</div>}
|
|
||||||
|
|
||||||
{/* Success Banner for New Orders */}
|
|
||||||
{isNewOrder && (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4 sm:p-6 mb-6">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-green-900 mb-2">
|
|
||||||
Order Submitted Successfully!
|
|
||||||
</h3>
|
|
||||||
<p className="text-green-800 mb-3">
|
|
||||||
Your order has been created and submitted for processing. We will notify you as soon
|
|
||||||
as it's approved and ready for activation.
|
|
||||||
</p>
|
|
||||||
<div className="text-sm text-green-700">
|
|
||||||
<p className="mb-1">
|
|
||||||
<strong>What happens next:</strong>
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
|
||||||
<li>Our team will review your order (within 1 business day)</li>
|
|
||||||
<li>You'll receive an email confirmation once approved</li>
|
|
||||||
<li>We will schedule activation based on your preferences</li>
|
|
||||||
<li>This page will update automatically as your order progresses</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status Section - Moved to top */}
|
|
||||||
{data &&
|
|
||||||
(() => {
|
|
||||||
const statusInfo = getDetailedStatusInfo(
|
|
||||||
data.status,
|
|
||||||
data.activationStatus,
|
|
||||||
data.activationType,
|
|
||||||
data.scheduledAt
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusVariant = statusInfo.label.includes("Active")
|
|
||||||
? "success"
|
|
||||||
: statusInfo.label.includes("Review") ||
|
|
||||||
statusInfo.label.includes("Setting Up") ||
|
|
||||||
statusInfo.label.includes("Scheduled")
|
|
||||||
? "info"
|
|
||||||
: "neutral";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubCard
|
|
||||||
className="mb-9"
|
|
||||||
header={<h3 className="text-xl font-bold text-gray-900">Status</h3>}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
|
||||||
<div className="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div>
|
|
||||||
<StatusPill
|
|
||||||
label={statusInfo.label}
|
|
||||||
variant={statusVariant as "info" | "success" | "warning" | "error"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Highlighted Next Steps Section */}
|
|
||||||
{statusInfo.nextAction && (
|
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
|
|
||||||
<p className="font-bold text-blue-900 text-base">Next Steps</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-blue-800 text-base leading-relaxed">{statusInfo.nextAction}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{statusInfo.timeline && (
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
<span className="font-medium">Timeline:</span> {statusInfo.timeline}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Combined Service Overview and Products */}
|
|
||||||
{data && (
|
|
||||||
<div className="bg-white border rounded-2xl p-4 sm:p-8 mb-8">
|
|
||||||
{/* Service Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6 mb-6">
|
|
||||||
<div className="flex items-center text-3xl sm:text-4xl">
|
|
||||||
{getServiceTypeIcon(data.orderType)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center">
|
|
||||||
{data.orderType} Service
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-4 text-sm sm:text-base">
|
|
||||||
Order #{data.orderNumber || data.id.slice(-8)} • Placed{" "}
|
|
||||||
{new Date(data.createdDate).toLocaleDateString("en-US", {
|
|
||||||
weekday: "long",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.items &&
|
|
||||||
data.items.length > 0 &&
|
|
||||||
(() => {
|
|
||||||
const totals = calculateDetailedTotals(data.items);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-left sm:text-right w-full sm:w-auto mt-2 sm:mt-0">
|
|
||||||
<div className="space-y-2 sm:space-y-2">
|
|
||||||
{totals.monthlyTotal > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900 tabular-nums">
|
|
||||||
¥{totals.monthlyTotal.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">per month</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totals.oneTimeTotal > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-orange-600 tabular-nums">
|
|
||||||
¥{totals.oneTimeTotal.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">one-time</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fallback to TotalAmount if no items or calculation fails */}
|
|
||||||
{totals.monthlyTotal === 0 &&
|
|
||||||
totals.oneTimeTotal === 0 &&
|
|
||||||
data.totalAmount && (
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900 tabular-nums">
|
|
||||||
¥{data.totalAmount.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">total amount</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Services & Products Section */}
|
|
||||||
{data?.items && data.items.length > 0 && (
|
|
||||||
<div className="border-t pt-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Your Services & Products</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{data.items
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort: Services first, then Installations, then others
|
|
||||||
const aIsService = a.product.itemClass === "Service";
|
|
||||||
const bIsService = b.product.itemClass === "Service";
|
|
||||||
const aIsInstallation = a.product.itemClass === "Installation";
|
|
||||||
const bIsInstallation = b.product.itemClass === "Installation";
|
|
||||||
|
|
||||||
if (aIsService && !bIsService) return -1;
|
|
||||||
if (!aIsService && bIsService) return 1;
|
|
||||||
if (aIsInstallation && !bIsInstallation) return -1;
|
|
||||||
if (!aIsInstallation && bIsInstallation) return 1;
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
.map(item => {
|
|
||||||
// Use the actual Item_Class__c values from Salesforce documentation
|
|
||||||
const itemClass = item.product.itemClass;
|
|
||||||
|
|
||||||
// Get appropriate icon and color based on item type and billing cycle
|
|
||||||
const getItemTypeInfo = () => {
|
|
||||||
const isMonthly = item.product.billingCycle === "Monthly";
|
|
||||||
const isService = itemClass === "Service";
|
|
||||||
const isInstallation = itemClass === "Installation";
|
|
||||||
|
|
||||||
if (isService && isMonthly) {
|
|
||||||
// Main service products - Blue theme
|
|
||||||
return {
|
|
||||||
icon: <StarIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-blue-50 border-blue-200",
|
|
||||||
iconBg: "bg-blue-100 text-blue-600",
|
|
||||||
label: itemClass || "Service",
|
|
||||||
labelColor: "text-blue-600",
|
|
||||||
};
|
|
||||||
} else if (isInstallation) {
|
|
||||||
// Installation items - Green theme
|
|
||||||
return {
|
|
||||||
icon: <WrenchScrewdriverIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-green-50 border-green-200",
|
|
||||||
iconBg: "bg-green-100 text-green-600",
|
|
||||||
label: itemClass || "Installation",
|
|
||||||
labelColor: "text-green-600",
|
|
||||||
};
|
|
||||||
} else if (isMonthly) {
|
|
||||||
// Other monthly products - Blue theme
|
|
||||||
return {
|
|
||||||
icon: <StarIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-blue-50 border-blue-200",
|
|
||||||
iconBg: "bg-blue-100 text-blue-600",
|
|
||||||
label: itemClass || "Service",
|
|
||||||
labelColor: "text-blue-600",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// One-time products - Orange theme
|
|
||||||
return {
|
|
||||||
icon: <CubeIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-orange-50 border-orange-200",
|
|
||||||
iconBg: "bg-orange-100 text-orange-600",
|
|
||||||
label: itemClass || "Add-on",
|
|
||||||
labelColor: "text-orange-600",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeInfo = getItemTypeInfo();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={`rounded-lg p-4 border ${typeInfo.bg} transition-shadow hover:shadow-sm`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
|
|
||||||
<div className="flex items-start gap-3 flex-1">
|
|
||||||
<div
|
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
|
|
||||||
>
|
|
||||||
{typeInfo.icon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
|
|
||||||
{item.product.name}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
|
|
||||||
>
|
|
||||||
{typeInfo.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600">
|
|
||||||
<span className="font-medium">{item.product.billingCycle}</span>
|
|
||||||
{item.quantity > 1 && <span>Qty: {item.quantity}</span>}
|
|
||||||
{item.product.itemClass && (
|
|
||||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
|
|
||||||
{item.product.itemClass}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-left sm:text-right ml-0 sm:ml-3 mt-2 sm:mt-0 flex-shrink-0 sm:w-32">
|
|
||||||
{item.totalPrice && (
|
|
||||||
<div className="font-semibold text-gray-900 tabular-nums">
|
|
||||||
¥{item.totalPrice.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Additional fees warning */}
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-600 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-yellow-900">
|
|
||||||
Additional fees may apply
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-800 mt-1">
|
|
||||||
Weekend installation (+¥3,000), express setup, or special configuration
|
|
||||||
charges may be added. We will contact you before applying any additional
|
|
||||||
fees.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Support Contact */}
|
|
||||||
<SubCard title="Need Help?">
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-700 text-sm">
|
|
||||||
Questions about your order? Contact our support team.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto sm:justify-end">
|
|
||||||
<a
|
|
||||||
href="mailto:support@example.com"
|
|
||||||
className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2 justify-center w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<EnvelopeIcon className="h-4 w-4" />
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="tel:+1234567890"
|
|
||||||
className="bg-white text-blue-600 border border-blue-600 px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors flex items-center gap-2 justify-center w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<PhoneIcon className="h-4 w-4" />
|
|
||||||
Call
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,333 +1,5 @@
|
|||||||
"use client";
|
import OrdersListContainer from "@/features/orders/views/OrdersList";
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import {
|
|
||||||
ClipboardDocumentListIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
WifiIcon,
|
|
||||||
DevicePhoneMobileIcon,
|
|
||||||
LockClosedIcon,
|
|
||||||
CubeIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
interface OrderSummary {
|
|
||||||
id: string;
|
|
||||||
orderNumber?: string;
|
|
||||||
status: string;
|
|
||||||
orderType?: string;
|
|
||||||
effectiveDate?: string;
|
|
||||||
totalAmount?: number;
|
|
||||||
createdDate: string;
|
|
||||||
activationStatus?: string;
|
|
||||||
itemSummary?: string;
|
|
||||||
itemsSummary?: Array<{
|
|
||||||
name?: string;
|
|
||||||
sku?: string;
|
|
||||||
itemClass?: string;
|
|
||||||
quantity: number;
|
|
||||||
unitPrice?: number;
|
|
||||||
totalPrice?: number;
|
|
||||||
billingCycle?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusInfo {
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
bgColor: string;
|
|
||||||
description: string;
|
|
||||||
nextAction?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OrdersSuccessBanner() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const showSuccess = searchParams.get("status") === "success";
|
|
||||||
if (!showSuccess) return null;
|
|
||||||
return (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-green-900 mb-2">
|
|
||||||
Order Submitted Successfully!
|
|
||||||
</h3>
|
|
||||||
<p className="text-green-800">
|
|
||||||
Your order has been created and is now being processed. You can track its progress
|
|
||||||
below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const router = useRouter();
|
return <OrdersListContainer />;
|
||||||
const [orders, setOrders] = useState<OrderSummary[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchOrders = async () => {
|
|
||||||
try {
|
|
||||||
const data = await authenticatedApi.get<OrderSummary[]>("/orders/user");
|
|
||||||
setOrders(data);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load orders");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void fetchOrders();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => {
|
|
||||||
// Combine order status and activation status for better UX
|
|
||||||
if (activationStatus === "Activated") {
|
|
||||||
return {
|
|
||||||
label: "Active",
|
|
||||||
color: "text-green-800",
|
|
||||||
bgColor: "bg-green-100",
|
|
||||||
description: "Your service is active and ready to use",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "Draft" || status === "Pending Review") {
|
|
||||||
return {
|
|
||||||
label: "Under Review",
|
|
||||||
color: "text-blue-800",
|
|
||||||
bgColor: "bg-blue-100",
|
|
||||||
description: "We're reviewing your order",
|
|
||||||
nextAction: "We'll contact you within 1 business day",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activationStatus === "Activating") {
|
|
||||||
return {
|
|
||||||
label: "Setting Up",
|
|
||||||
color: "text-orange-800",
|
|
||||||
bgColor: "bg-orange-100",
|
|
||||||
description: "We're preparing your service",
|
|
||||||
nextAction: "Installation will be scheduled soon",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: status || "Processing",
|
|
||||||
color: "text-gray-800",
|
|
||||||
bgColor: "bg-gray-100",
|
|
||||||
description: "Order is being processed",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getServiceTypeDisplay = (orderType?: string) => {
|
|
||||||
switch (orderType) {
|
|
||||||
case "Internet":
|
|
||||||
return { icon: <WifiIcon className="h-6 w-6" />, label: "Internet Service" };
|
|
||||||
case "SIM":
|
|
||||||
return { icon: <DevicePhoneMobileIcon className="h-6 w-6" />, label: "Mobile Service" };
|
|
||||||
case "VPN":
|
|
||||||
return { icon: <LockClosedIcon className="h-6 w-6" />, label: "VPN Service" };
|
|
||||||
default:
|
|
||||||
return { icon: <CubeIcon className="h-6 w-6" />, label: "Service" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getServiceSummary = (order: OrderSummary) => {
|
|
||||||
if (order.itemsSummary && order.itemsSummary.length > 0) {
|
|
||||||
const mainItem = order.itemsSummary[0];
|
|
||||||
const additionalCount = order.itemsSummary.length - 1;
|
|
||||||
|
|
||||||
let summary = mainItem.name || "Service";
|
|
||||||
if (additionalCount > 0) {
|
|
||||||
summary += ` +${additionalCount} more`;
|
|
||||||
}
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
return order.itemSummary || "Service package";
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateOrderTotals = (order: OrderSummary) => {
|
|
||||||
let monthlyTotal = 0;
|
|
||||||
let oneTimeTotal = 0;
|
|
||||||
|
|
||||||
// If we have items with billing cycle information, calculate totals from items
|
|
||||||
if (order.itemsSummary && order.itemsSummary.length > 0) {
|
|
||||||
order.itemsSummary.forEach(item => {
|
|
||||||
const totalPrice = item.totalPrice || 0;
|
|
||||||
const billingCycle = item.billingCycle?.toLowerCase() || "";
|
|
||||||
|
|
||||||
if (billingCycle === "monthly") {
|
|
||||||
monthlyTotal += totalPrice;
|
|
||||||
} else {
|
|
||||||
// All other billing cycles (one-time, annual, etc.) are considered one-time
|
|
||||||
oneTimeTotal += totalPrice;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback to totalAmount if no item details available
|
|
||||||
// Assume it's monthly for backward compatibility
|
|
||||||
monthlyTotal = order.totalAmount || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthlyTotal,
|
|
||||||
oneTimeTotal,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ClipboardDocumentListIcon />}
|
|
||||||
title="My Orders"
|
|
||||||
description="View and track all your orders"
|
|
||||||
>
|
|
||||||
{/* Success Banner (Suspense for useSearchParams) */}
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<OrdersSuccessBanner />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
|
||||||
<p className="text-red-800">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="bg-white border rounded-xl p-8 text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Loading your orders...</p>
|
|
||||||
</div>
|
|
||||||
) : orders.length === 0 ? (
|
|
||||||
<div className="bg-white border rounded-xl p-8 text-center">
|
|
||||||
<ClipboardDocumentListIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No orders yet</h3>
|
|
||||||
<p className="text-gray-600 mb-4">You haven't placed any orders yet.</p>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/catalog")}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Browse Catalog
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{orders.map(order => {
|
|
||||||
const statusInfo = getStatusInfo(order.status, order.activationStatus);
|
|
||||||
const serviceType = getServiceTypeDisplay(order.orderType);
|
|
||||||
const serviceSummary = getServiceSummary(order);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={order.id}
|
|
||||||
className="bg-white border border-gray-200 rounded-2xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 cursor-pointer group"
|
|
||||||
onClick={() => router.push(`/orders/${order.id}`)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="text-2xl">{serviceType.icon}</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
|
|
||||||
{serviceType.label}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Order #{order.orderNumber || order.id.slice(-8)} •{" "}
|
|
||||||
{new Date(order.createdDate).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<StatusPill
|
|
||||||
label={statusInfo.label}
|
|
||||||
variant={
|
|
||||||
statusInfo.label === "Active"
|
|
||||||
? "success"
|
|
||||||
: statusInfo.label === "Setting Up" || statusInfo.label === "Under Review"
|
|
||||||
? "info"
|
|
||||||
: "neutral"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Details */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{serviceSummary}</p>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{statusInfo.description}</p>
|
|
||||||
{statusInfo.nextAction && (
|
|
||||||
<p className="text-sm text-blue-600 mt-1 font-medium">
|
|
||||||
{statusInfo.nextAction}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const totals = calculateOrderTotals(order);
|
|
||||||
if (totals.monthlyTotal <= 0 && totals.oneTimeTotal <= 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
¥{totals.monthlyTotal.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">per month</p>
|
|
||||||
|
|
||||||
{totals.oneTimeTotal > 0 && (
|
|
||||||
<>
|
|
||||||
<p className="text-lg font-semibold text-orange-600">
|
|
||||||
¥{totals.oneTimeTotal.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">one-time</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fee Disclaimer */}
|
|
||||||
<div className="mt-3 text-xs text-gray-500 text-left">
|
|
||||||
<p>* Additional fees may apply</p>
|
|
||||||
<p className="text-gray-400">(e.g., weekend installation)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Indicator */}
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Click to view details</span>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-gray-400 group-hover:text-blue-500 transition-colors"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,495 +1,5 @@
|
|||||||
"use client";
|
import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
|
||||||
ArrowLeftIcon,
|
|
||||||
ServerIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
ArrowTopRightOnSquareIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useSubscription, useSubscriptionInvoices } from "@/hooks/useSubscriptions";
|
|
||||||
import { formatCurrency as sharedFormatCurrency, getCurrencyLocale } from "@/utils/currency";
|
|
||||||
import { SimManagementSection } from "@/features/sim-management";
|
|
||||||
|
|
||||||
export default function SubscriptionDetailPage() {
|
export default function SubscriptionDetailPage() {
|
||||||
const router = useRouter();
|
return <SubscriptionDetailContainer />;
|
||||||
const params = useParams();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const itemsPerPage = 10;
|
|
||||||
const [showInvoices, setShowInvoices] = useState(true);
|
|
||||||
const [showSimManagement, setShowSimManagement] = useState(false);
|
|
||||||
|
|
||||||
const subscriptionId = parseInt(params.id as string);
|
|
||||||
const { data: subscription, isLoading, error } = useSubscription(subscriptionId);
|
|
||||||
const {
|
|
||||||
data: invoiceData,
|
|
||||||
isLoading: invoicesLoading,
|
|
||||||
error: invoicesError,
|
|
||||||
} = useSubscriptionInvoices(subscriptionId, { page: currentPage, limit: itemsPerPage });
|
|
||||||
|
|
||||||
const invoices = invoiceData?.invoices || [];
|
|
||||||
const pagination = invoiceData?.pagination;
|
|
||||||
|
|
||||||
// Control what sections to show based on URL hash
|
|
||||||
useEffect(() => {
|
|
||||||
const updateVisibility = () => {
|
|
||||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
|
||||||
const service = (searchParams.get("service") || "").toLowerCase();
|
|
||||||
const isSimContext = hash.includes("sim-management") || service === "sim";
|
|
||||||
|
|
||||||
if (isSimContext) {
|
|
||||||
// Show only SIM management, hide invoices
|
|
||||||
setShowInvoices(false);
|
|
||||||
setShowSimManagement(true);
|
|
||||||
} else {
|
|
||||||
// Show only invoices, hide SIM management
|
|
||||||
setShowInvoices(true);
|
|
||||||
setShowSimManagement(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
updateVisibility();
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.addEventListener("hashchange", updateVisibility);
|
|
||||||
return () => window.removeEventListener("hashchange", updateVisibility);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Active":
|
|
||||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
|
||||||
case "Suspended":
|
|
||||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
|
||||||
case "Terminated":
|
|
||||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
|
||||||
case "Cancelled":
|
|
||||||
return <XCircleIcon className="h-6 w-6 text-gray-500" />;
|
|
||||||
case "Pending":
|
|
||||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
|
||||||
default:
|
|
||||||
return <ServerIcon className="h-6 w-6 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Active":
|
|
||||||
return "bg-green-100 text-green-800";
|
|
||||||
case "Suspended":
|
|
||||||
return "bg-yellow-100 text-yellow-800";
|
|
||||||
case "Terminated":
|
|
||||||
return "bg-red-100 text-red-800";
|
|
||||||
case "Cancelled":
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
case "Pending":
|
|
||||||
return "bg-blue-100 text-blue-800";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInvoiceStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Paid":
|
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
|
||||||
case "Overdue":
|
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
|
|
||||||
case "Unpaid":
|
|
||||||
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
|
|
||||||
default:
|
|
||||||
return <DocumentTextIcon className="h-5 w-5 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInvoiceStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Paid":
|
|
||||||
return "bg-green-100 text-green-800";
|
|
||||||
case "Overdue":
|
|
||||||
return "bg-red-100 text-red-800";
|
|
||||||
case "Unpaid":
|
|
||||||
return "bg-yellow-100 text-yellow-800";
|
|
||||||
case "Cancelled":
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined) => {
|
|
||||||
if (!dateString) return "N/A";
|
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) =>
|
|
||||||
sharedFormatCurrency(amount || 0, { currency: "JPY", locale: getCurrencyLocale("JPY") });
|
|
||||||
|
|
||||||
const formatBillingLabel = (cycle: string) => {
|
|
||||||
switch (cycle) {
|
|
||||||
case "Monthly":
|
|
||||||
return "Monthly Billing";
|
|
||||||
case "Annually":
|
|
||||||
return "Annual Billing";
|
|
||||||
case "Quarterly":
|
|
||||||
return "Quarterly Billing";
|
|
||||||
case "Semi-Annually":
|
|
||||||
return "Semi-Annual Billing";
|
|
||||||
case "Biennially":
|
|
||||||
return "Biennial Billing";
|
|
||||||
case "Triennially":
|
|
||||||
return "Triennial Billing";
|
|
||||||
default:
|
|
||||||
return "One-time Payment";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
||||||
<p className="mt-4 text-gray-600">Loading subscription...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !subscription) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">Error loading subscription</h3>
|
|
||||||
<div className="mt-2 text-sm text-red-700">
|
|
||||||
{error instanceof Error ? error.message : "Subscription not found"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Link href="/subscriptions" className="text-red-700 hover:text-red-600 font-medium">
|
|
||||||
← Back to subscriptions
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="py-6">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Link href="/subscriptions" className="mr-4 text-gray-600 hover:text-gray-900">
|
|
||||||
<ArrowLeftIcon className="h-6 w-6" />
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<ServerIcon className="h-8 w-8 text-blue-600 mr-3" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{subscription.productName}</h1>
|
|
||||||
<p className="text-gray-600">Service ID: {subscription.serviceId}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subscription Summary Card */}
|
|
||||||
<div className="bg-white shadow rounded-lg mb-6">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
{getStatusIcon(subscription.status)}
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">Subscription Details</h3>
|
|
||||||
<p className="text-sm text-gray-500">Service subscription information</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(subscription.status)}`}
|
|
||||||
>
|
|
||||||
{subscription.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Billing Amount
|
|
||||||
</h4>
|
|
||||||
<p className="mt-2 text-2xl font-bold text-gray-900">
|
|
||||||
{formatCurrency(subscription.amount)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">{formatBillingLabel(subscription.cycle)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Next Due Date
|
|
||||||
</h4>
|
|
||||||
<p className="mt-2 text-lg text-gray-900">{formatDate(subscription.nextDue)}</p>
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<CalendarIcon className="h-4 w-4 text-gray-400 mr-1" />
|
|
||||||
<span className="text-sm text-gray-500">Due date</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Registration Date
|
|
||||||
</h4>
|
|
||||||
<p className="mt-2 text-lg text-gray-900">
|
|
||||||
{formatDate(subscription.registrationDate)}
|
|
||||||
</p>
|
|
||||||
<span className="text-sm text-gray-500">Service created</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation tabs for SIM services - More visible and mobile-friendly */}
|
|
||||||
{subscription.productName.toLowerCase().includes("sim") && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Switch between billing and SIM management views
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
|
||||||
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${
|
|
||||||
showSimManagement
|
|
||||||
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
|
|
||||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ServerIcon className="h-4 w-4 inline mr-2" />
|
|
||||||
SIM Management
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}`}
|
|
||||||
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
|
|
||||||
showInvoices
|
|
||||||
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
|
|
||||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
|
|
||||||
Billing
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SIM Management Section - Only show when in SIM context and for SIM services */}
|
|
||||||
{showSimManagement && subscription.productName.toLowerCase().includes("sim") && (
|
|
||||||
<SimManagementSection subscriptionId={subscriptionId} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Related Invoices (hidden when viewing SIM management directly) */}
|
|
||||||
{showInvoices && (
|
|
||||||
<div className="bg-white shadow rounded-lg">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">Related Invoices</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Invoices containing charges for this subscription
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{invoicesLoading ? (
|
|
||||||
<div className="px-6 py-8 text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
|
||||||
<p className="mt-2 text-gray-600">Loading invoices...</p>
|
|
||||||
</div>
|
|
||||||
) : invoicesError ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-red-800">Error loading invoices</h3>
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{invoicesError instanceof Error
|
|
||||||
? invoicesError.message
|
|
||||||
: "Failed to load related invoices"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : invoices.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No invoices found</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
No invoices have been generated for this subscription yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{invoices.map(invoice => (
|
|
||||||
<div
|
|
||||||
key={invoice.id}
|
|
||||||
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 group"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center flex-1">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{getInvoiceStatusIcon(invoice.status)}
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<h4 className="text-base font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
|
|
||||||
Invoice {invoice.number}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Issued{" "}
|
|
||||||
{invoice.issuedAt &&
|
|
||||||
format(new Date(invoice.issuedAt), "MMM d, yyyy")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end space-y-2">
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-3 py-1 text-sm font-medium rounded-full ${getInvoiceStatusColor(invoice.status)}`}
|
|
||||||
>
|
|
||||||
{invoice.status}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-gray-900">
|
|
||||||
{formatCurrency(invoice.total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
<span className="block">
|
|
||||||
Due:{" "}
|
|
||||||
{invoice.dueDate
|
|
||||||
? format(new Date(invoice.dueDate), "MMM d, yyyy")
|
|
||||||
: "N/A"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/billing/invoices/${invoice.id}`)}
|
|
||||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
||||||
>
|
|
||||||
<DocumentTextIcon className="h-4 w-4 mr-2" />
|
|
||||||
View Invoice
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
|
||||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
|
|
||||||
}
|
|
||||||
disabled={currentPage === pagination.totalPages}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{(currentPage - 1) * itemsPerPage + 1}
|
|
||||||
</span>{" "}
|
|
||||||
to{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(currentPage * itemsPerPage, pagination.totalItems)}
|
|
||||||
</span>{" "}
|
|
||||||
of <span className="font-medium">{pagination.totalItems}</span> results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
|
|
||||||
const startPage = Math.max(1, currentPage - 2);
|
|
||||||
const page = startPage + i;
|
|
||||||
if (page > pagination.totalPages) return null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={page}
|
|
||||||
onClick={() => setCurrentPage(page)}
|
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
|
||||||
page === currentPage
|
|
||||||
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
|
|
||||||
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
|
|
||||||
}
|
|
||||||
disabled={currentPage === pagination.totalPages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,352 +1,5 @@
|
|||||||
"use client";
|
import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
|
||||||
import { formatPlanShort } from "@/lib/plan";
|
|
||||||
|
|
||||||
type Step = 1 | 2 | 3;
|
|
||||||
|
|
||||||
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
|
|
||||||
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
|
|
||||||
<div className="text-sm text-yellow-800">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500">{label}</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SimCancelPage() {
|
export default function SimCancelPage() {
|
||||||
const params = useParams();
|
return <SimCancelContainer />;
|
||||||
const router = useRouter();
|
|
||||||
const subscriptionId = parseInt(params.id as string);
|
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>(1);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [details, setDetails] = useState<SimDetails | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [acceptTerms, setAcceptTerms] = useState(false);
|
|
||||||
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
|
|
||||||
const [cancelMonth, setCancelMonth] = useState<string>(""); // YYYYMM
|
|
||||||
const [email, setEmail] = useState<string>("");
|
|
||||||
const [email2, setEmail2] = useState<string>("");
|
|
||||||
const [notes, setNotes] = useState<string>("");
|
|
||||||
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchDetails = async () => {
|
|
||||||
try {
|
|
||||||
const d = await authenticatedApi.get<SimDetails>(
|
|
||||||
`/subscriptions/${subscriptionId}/sim/details`
|
|
||||||
);
|
|
||||||
setDetails(d);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load SIM details");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void fetchDetails();
|
|
||||||
}, [subscriptionId]);
|
|
||||||
|
|
||||||
// Fetch registered email (from WHMCS billing info)
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchEmail = async () => {
|
|
||||||
try {
|
|
||||||
const billing = await authenticatedApi.get<{ email?: string }>(`/me/billing`);
|
|
||||||
if (billing?.email) setRegisteredEmail(billing.email);
|
|
||||||
} catch {
|
|
||||||
// Non-fatal; leave as null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void fetchEmail();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const monthOptions = useMemo(() => {
|
|
||||||
const opts: { value: string; label: string }[] = [];
|
|
||||||
const now = new Date();
|
|
||||||
// start from next month, 12 options
|
|
||||||
for (let i = 1; i <= 12; i++) {
|
|
||||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1));
|
|
||||||
const y = d.getUTCFullYear();
|
|
||||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
opts.push({ value: `${y}${m}`, label: `${y} / ${m}` });
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const canProceedStep2 = !!details;
|
|
||||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
const emailProvided = email.trim().length > 0 || email2.trim().length > 0;
|
|
||||||
const emailValid =
|
|
||||||
!emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim()));
|
|
||||||
const emailsMatch = !emailProvided || email.trim() === email2.trim();
|
|
||||||
const canProceedStep3 =
|
|
||||||
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
|
||||||
const runDate = cancelMonth ? `${cancelMonth}01` : undefined; // YYYYMM01
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setMessage(null);
|
|
||||||
try {
|
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {
|
|
||||||
scheduledAt: runDate,
|
|
||||||
});
|
|
||||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
|
||||||
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
|
||||||
className="text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
← Back to SIM Management
|
|
||||||
</Link>
|
|
||||||
<div className="text-sm text-gray-500">Step {step} of 3</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>
|
|
||||||
)}
|
|
||||||
{message && (
|
|
||||||
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
|
||||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
|
|
||||||
terminate your service immediately.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Notice title="Cancellation Procedure">
|
|
||||||
Online cancellations must be made from this website by the 25th of the desired
|
|
||||||
cancellation month. Once a request of a cancellation of the SONIXNET SIM is
|
|
||||||
accepted from this online form, a confirmation email containing details of the SIM
|
|
||||||
plan will be sent to the registered email address. The SIM card is a rental piece
|
|
||||||
of hardware and must be returned to Assist Solutions upon cancellation. The
|
|
||||||
cancellation request through this website retains to your SIM subscriptions only.
|
|
||||||
To cancel any other services with Assist Solutions (home internet etc.) please
|
|
||||||
contact Assist Solutions at info@asolutions.co.jp
|
|
||||||
</Notice>
|
|
||||||
<Notice title="Minimum Contract Term">
|
|
||||||
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
|
|
||||||
month is not included in the minimum term of three months; ie. sign-up in January
|
|
||||||
= minimum term is February, March, April). If the minimum contract term is not
|
|
||||||
fulfilled, the monthly fees of the remaining months will be charged upon
|
|
||||||
cancellation.
|
|
||||||
</Notice>
|
|
||||||
<Notice title="Option Services">
|
|
||||||
Cancellation of option services only (Voice Mail, Call Waiting) while keeping the
|
|
||||||
base plan active is not possible from this online form. Please contact Assist
|
|
||||||
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
|
|
||||||
cancelling the base plan, all additional options associated with the requested SIM
|
|
||||||
plan will be cancelled.
|
|
||||||
</Notice>
|
|
||||||
<Notice title="MNP Transfer (Voice Plans)">
|
|
||||||
Upon cancellation the SIM phone number will be lost. In order to keep the phone
|
|
||||||
number active to be used with a different cellular provider, a request for an MNP
|
|
||||||
transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be
|
|
||||||
requested from this online form. Please contact Assist Solutions Customer Support
|
|
||||||
(info@asolutions.co.jp) for more information.
|
|
||||||
</Notice>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
|
||||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
|
||||||
<InfoRow label="Start Date" value={details?.startDate || "—"} />
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Cancellation Month
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={cancelMonth}
|
|
||||||
onChange={e => {
|
|
||||||
setCancelMonth(e.target.value);
|
|
||||||
// Require re-confirmation if month changes
|
|
||||||
setConfirmMonthEnd(false);
|
|
||||||
}}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Select month…</option>
|
|
||||||
{monthOptions.map(opt => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Cancellation takes effect at the start of the selected month.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="acceptTerms"
|
|
||||||
type="checkbox"
|
|
||||||
checked={acceptTerms}
|
|
||||||
onChange={e => setAcceptTerms(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
|
|
||||||
I have read and accepted the conditions above.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
id="confirmMonthEnd"
|
|
||||||
type="checkbox"
|
|
||||||
checked={confirmMonthEnd}
|
|
||||||
onChange={e => setConfirmMonthEnd(e.target.checked)}
|
|
||||||
disabled={!cancelMonth}
|
|
||||||
/>
|
|
||||||
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
|
|
||||||
I would like to cancel my SonixNet SIM subscription at the end of the selected
|
|
||||||
month above.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={!canProceedStep3}
|
|
||||||
onClick={() => setStep(3)}
|
|
||||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Notice title="For Voice-enabled SIM subscriptions:">
|
|
||||||
Calling charges are post payment. Your bill for the final month's calling
|
|
||||||
charges will be charged on your credit card on file during the first week of the
|
|
||||||
second month after the cancellation. If you would like to make the payment with a
|
|
||||||
different credit card, please contact Assist Solutions at{" "}
|
|
||||||
<a href="mailto:info@asolutions.co.jp" className="text-blue-600 underline">
|
|
||||||
info@asolutions.co.jp
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Notice>
|
|
||||||
{registeredEmail && (
|
|
||||||
<div className="text-sm text-gray-800">
|
|
||||||
Your registered email address is:{" "}
|
|
||||||
<span className="font-medium">{registeredEmail}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-sm text-gray-700">
|
|
||||||
You will receive a cancellation confirmation email. If you would like to receive
|
|
||||||
this email on a different address, please enter the address below.
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">Email address</label>
|
|
||||||
<input
|
|
||||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">(Confirm)</label>
|
|
||||||
<input
|
|
||||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
value={email2}
|
|
||||||
onChange={e => setEmail2(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
If you have any other questions/comments/requests regarding your cancellation,
|
|
||||||
please note them below and an Assist Solutions staff will contact you shortly.
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
rows={4}
|
|
||||||
value={notes}
|
|
||||||
onChange={e => setNotes(e.target.value)}
|
|
||||||
placeholder="If you have any questions or requests, note them here."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Validation messages for email fields */}
|
|
||||||
{emailProvided && !emailValid && (
|
|
||||||
<div className="text-xs text-red-600">
|
|
||||||
Please enter a valid email address in both fields.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{emailProvided && emailValid && !emailsMatch && (
|
|
||||||
<div className="text-xs text-red-600">Email addresses do not match.</div>
|
|
||||||
)}
|
|
||||||
<div className="text-sm text-gray-700">
|
|
||||||
Your cancellation request is not confirmed yet. This is the final page. To finalize
|
|
||||||
your cancellation request please proceed from REQUEST CANCELLATION below.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => setStep(2)}
|
|
||||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
"Request cancellation now? This will schedule the cancellation for " +
|
|
||||||
(runDate || "") +
|
|
||||||
"."
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
void submit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading || !runDate || !canProceedStep3}
|
|
||||||
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? "Processing…" : "Request Cancellation"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,145 +1,5 @@
|
|||||||
"use client";
|
import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
|
|
||||||
type PlanCode = (typeof PLAN_CODES)[number];
|
|
||||||
const PLAN_LABELS: Record<PlanCode, string> = {
|
|
||||||
PASI_5G: "5GB",
|
|
||||||
PASI_10G: "10GB",
|
|
||||||
PASI_25G: "25GB",
|
|
||||||
PASI_50G: "50GB",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SimChangePlanPage() {
|
export default function SimChangePlanPage() {
|
||||||
const params = useParams();
|
return <SimChangePlanContainer />;
|
||||||
const subscriptionId = parseInt(params.id as string);
|
|
||||||
const [currentPlanCode] = useState<string>("");
|
|
||||||
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
|
||||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
|
||||||
const [scheduledAt, setScheduledAt] = useState("");
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const options = useMemo(
|
|
||||||
() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)),
|
|
||||||
[currentPlanCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newPlanCode) {
|
|
||||||
setError("Please select a new plan");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
|
||||||
newPlanCode,
|
|
||||||
assignGlobalIp,
|
|
||||||
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
|
|
||||||
});
|
|
||||||
setMessage("Plan change submitted successfully");
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to change plan");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
|
||||||
className="text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
← Back to SIM Management
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1>
|
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
|
||||||
Change Plan: Switch to a different data plan. Important: Plan changes must be requested
|
|
||||||
before the 25th of the month. Changes will take effect on the 1st of the following
|
|
||||||
month.
|
|
||||||
</p>
|
|
||||||
{message && (
|
|
||||||
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
|
||||||
<select
|
|
||||||
value={newPlanCode}
|
|
||||||
onChange={e => setNewPlanCode(e.target.value as PlanCode)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
||||||
>
|
|
||||||
<option value="">Choose a plan</option>
|
|
||||||
{options.map(code => (
|
|
||||||
<option key={code} value={code}>
|
|
||||||
{PLAN_LABELS[code]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
id="globalip"
|
|
||||||
type="checkbox"
|
|
||||||
checked={assignGlobalIp}
|
|
||||||
onChange={e => setAssignGlobalIp(e.target.checked)}
|
|
||||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">
|
|
||||||
Assign global IP
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Schedule (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={scheduledAt}
|
|
||||||
onChange={e => setScheduledAt(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? "Processing…" : "Submit Plan Change"}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
|
||||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,168 +1,5 @@
|
|||||||
"use client";
|
import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { authenticatedApi } from "@/lib/api";
|
|
||||||
|
|
||||||
export default function SimTopUpPage() {
|
export default function SimTopUpPage() {
|
||||||
const params = useParams();
|
return <SimTopUpContainer />;
|
||||||
const subscriptionId = parseInt(params.id as string);
|
|
||||||
const [gbAmount, setGbAmount] = useState<string>("1");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const getCurrentAmountMb = () => {
|
|
||||||
const gb = parseInt(gbAmount, 10);
|
|
||||||
return isNaN(gb) ? 0 : gb * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidAmount = () => {
|
|
||||||
const gb = Number(gbAmount);
|
|
||||||
return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB in whole numbers (Freebit API limit)
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateCost = () => {
|
|
||||||
const gb = parseInt(gbAmount, 10);
|
|
||||||
return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!isValidAmount()) {
|
|
||||||
setError("Please enter a whole number between 1 GB and 100 GB");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setMessage(null);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
|
|
||||||
quotaMb: getCurrentAmountMb(),
|
|
||||||
});
|
|
||||||
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to submit top-up");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
|
||||||
className="text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
← Back to SIM Management
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
|
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
|
||||||
Add additional data quota to your SIM service. Enter the amount of data you want to add.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
|
|
||||||
{/* Amount Input */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={gbAmount}
|
|
||||||
onChange={e => setGbAmount(e.target.value)}
|
|
||||||
placeholder="Enter amount in GB"
|
|
||||||
min="1"
|
|
||||||
max="50"
|
|
||||||
step="1"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
|
||||||
<span className="text-gray-500 text-sm">GB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cost Display */}
|
|
||||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-blue-900">
|
|
||||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-lg font-bold text-blue-900">
|
|
||||||
¥{calculateCost().toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validation Warning */}
|
|
||||||
{!isValidAmount() && gbAmount && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-red-500 mr-2"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm text-red-800">
|
|
||||||
Amount must be a whole number between 1 GB and 50 GB
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !isValidAmount()}
|
|
||||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
|
||||||
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,344 +1,5 @@
|
|||||||
"use client";
|
import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
|
||||||
import { SearchFilterBar } from "@/components/ui/search-filter-bar";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-skeleton";
|
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
|
||||||
import {
|
|
||||||
ServerIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
ArrowTopRightOnSquareIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
// (duplicate SubCard import removed)
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
|
|
||||||
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
|
||||||
import type { Subscription } from "@customer-portal/shared";
|
|
||||||
// Removed unused SubscriptionStatusBadge in favor of StatusPill
|
|
||||||
|
|
||||||
export default function SubscriptionsPage() {
|
export default function SubscriptionsPage() {
|
||||||
const router = useRouter();
|
return <SubscriptionsListContainer />;
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
|
|
||||||
// Fetch subscriptions and stats from API
|
|
||||||
const {
|
|
||||||
data: subscriptionData,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = useSubscriptions({
|
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
|
||||||
});
|
|
||||||
const { data: stats } = useSubscriptionStats();
|
|
||||||
|
|
||||||
// Handle both SubscriptionList and Subscription[] response types
|
|
||||||
const subscriptions = useMemo((): Subscription[] => {
|
|
||||||
if (!subscriptionData) return [];
|
|
||||||
if (Array.isArray(subscriptionData)) return subscriptionData;
|
|
||||||
return subscriptionData.subscriptions;
|
|
||||||
}, [subscriptionData]);
|
|
||||||
|
|
||||||
// Filter subscriptions based on search and status
|
|
||||||
const filteredSubscriptions = useMemo(() => {
|
|
||||||
if (!searchTerm) return subscriptions;
|
|
||||||
return subscriptions.filter(subscription => {
|
|
||||||
const matchesSearch =
|
|
||||||
subscription.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
subscription.serviceId.toString().includes(searchTerm);
|
|
||||||
return matchesSearch;
|
|
||||||
});
|
|
||||||
}, [subscriptions, searchTerm]);
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Active":
|
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
|
||||||
case "Suspended":
|
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
|
||||||
case "Pending":
|
|
||||||
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
|
||||||
case "Cancelled":
|
|
||||||
case "Terminated":
|
|
||||||
return <XCircleIcon className="h-5 w-5 text-gray-500" />;
|
|
||||||
default:
|
|
||||||
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
try {
|
|
||||||
return format(new Date(dateString), "MMM d, yyyy");
|
|
||||||
} catch {
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusFilterOptions = [
|
|
||||||
{ value: "all", label: "All Status" },
|
|
||||||
{ value: "Active", label: "Active" },
|
|
||||||
{ value: "Suspended", label: "Suspended" },
|
|
||||||
{ value: "Pending", label: "Pending" },
|
|
||||||
{ value: "Cancelled", label: "Cancelled" },
|
|
||||||
{ value: "Terminated", label: "Terminated" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "Active":
|
|
||||||
return "success" as const;
|
|
||||||
case "Suspended":
|
|
||||||
return "warning" as const;
|
|
||||||
case "Pending":
|
|
||||||
return "info" as const;
|
|
||||||
case "Cancelled":
|
|
||||||
case "Terminated":
|
|
||||||
return "neutral" as const;
|
|
||||||
default:
|
|
||||||
return "neutral" as const;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscriptionColumns = [
|
|
||||||
{
|
|
||||||
key: "service",
|
|
||||||
header: "Service",
|
|
||||||
render: (subscription: Subscription) => (
|
|
||||||
<div className="flex items-center">
|
|
||||||
{getStatusIcon(subscription.status)}
|
|
||||||
<div className="ml-3">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{subscription.productName}</div>
|
|
||||||
<div className="text-sm text-gray-500">Service ID: {subscription.serviceId}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
header: "Status",
|
|
||||||
render: (subscription: Subscription) => (
|
|
||||||
<StatusPill label={subscription.status} variant={getStatusVariant(subscription.status)} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cycle",
|
|
||||||
header: "Billing Cycle",
|
|
||||||
render: (subscription: Subscription) => {
|
|
||||||
const name = (subscription.productName || "").toLowerCase();
|
|
||||||
const looksLikeActivation =
|
|
||||||
name.includes("activation fee") || name.includes("activation") || name.includes("setup");
|
|
||||||
const displayCycle = looksLikeActivation ? "One-time" : subscription.cycle;
|
|
||||||
return <span className="text-sm text-gray-900">{displayCycle}</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "price",
|
|
||||||
header: "Price",
|
|
||||||
render: (subscription: Subscription) => (
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{formatCurrency(subscription.amount, {
|
|
||||||
currency: "JPY",
|
|
||||||
locale: getCurrencyLocale("JPY"),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{subscription.cycle === "Monthly"
|
|
||||||
? "per month"
|
|
||||||
: subscription.cycle === "Annually"
|
|
||||||
? "per year"
|
|
||||||
: subscription.cycle === "Quarterly"
|
|
||||||
? "per quarter"
|
|
||||||
: subscription.cycle === "Semi-Annually"
|
|
||||||
? "per 6 months"
|
|
||||||
: subscription.cycle === "Biennially"
|
|
||||||
? "per 2 years"
|
|
||||||
: subscription.cycle === "Triennially"
|
|
||||||
? "per 3 years"
|
|
||||||
: subscription.cycle === "One-time"
|
|
||||||
? "one-time"
|
|
||||||
: "one-time"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "nextDue",
|
|
||||||
header: "Next Due",
|
|
||||||
render: (subscription: Subscription) => (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{subscription.nextDue ? formatDate(subscription.nextDue) : "N/A"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "relative",
|
|
||||||
render: (subscription: Subscription) => (
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
<Link
|
|
||||||
href={`/subscriptions/${subscription.id}`}
|
|
||||||
className="text-blue-600 hover:text-blue-900 text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ServerIcon />}
|
|
||||||
title="Subscriptions"
|
|
||||||
description="Manage your active services and subscriptions"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<LoadingSpinner size="lg" />
|
|
||||||
<p className="text-muted-foreground">Loading subscriptions...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ServerIcon />}
|
|
||||||
title="Subscriptions"
|
|
||||||
description="Manage your active services and subscriptions"
|
|
||||||
>
|
|
||||||
<ErrorState
|
|
||||||
title="Error loading subscriptions"
|
|
||||||
message={error instanceof Error ? error.message : "An unexpected error occurred"}
|
|
||||||
variant="page"
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
icon={<ServerIcon />}
|
|
||||||
title="Subscriptions"
|
|
||||||
description="Manage your active services and subscriptions"
|
|
||||||
actions={
|
|
||||||
<Link
|
|
||||||
href="/catalog"
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Order Services
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Stats Cards */}
|
|
||||||
{/* Stats Cards */}
|
|
||||||
{stats && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
<SubCard>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<CheckCircleIcon className="h-8 w-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
|
|
||||||
<dd className="text-lg font-medium text-gray-900">{stats.active}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
<SubCard>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Suspended</dt>
|
|
||||||
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
<SubCard>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<ClockIcon className="h-8 w-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
|
|
||||||
<dd className="text-lg font-medium text-gray-900">{stats.pending}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
|
|
||||||
<SubCard>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<XCircleIcon className="h-8 w-8 text-gray-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt>
|
|
||||||
<dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SubCard>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subscriptions Table with integrated header + CTA */}
|
|
||||||
<SubCard
|
|
||||||
header={
|
|
||||||
<SearchFilterBar
|
|
||||||
searchValue={searchTerm}
|
|
||||||
onSearchChange={setSearchTerm}
|
|
||||||
searchPlaceholder="Search subscriptions..."
|
|
||||||
filterValue={statusFilter}
|
|
||||||
onFilterChange={setStatusFilter}
|
|
||||||
filterOptions={statusFilterOptions}
|
|
||||||
filterLabel="Filter by status"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
|
|
||||||
>
|
|
||||||
<DataTable
|
|
||||||
data={filteredSubscriptions}
|
|
||||||
columns={subscriptionColumns}
|
|
||||||
emptyState={{
|
|
||||||
icon: <ServerIcon className="h-12 w-12" />,
|
|
||||||
title: "No subscriptions found",
|
|
||||||
description:
|
|
||||||
searchTerm || statusFilter !== "all"
|
|
||||||
? "Try adjusting your search or filter criteria."
|
|
||||||
: "No active subscriptions at this time.",
|
|
||||||
}}
|
|
||||||
onRowClick={subscription => router.push(`/subscriptions/${subscription.id}`)}
|
|
||||||
/>
|
|
||||||
</SubCard>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +1,5 @@
|
|||||||
"use client";
|
import ForgotPasswordView from "@/features/auth/views/ForgotPasswordView";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
|
|
||||||
const schema = z.object({ email: z.string().email("Please enter a valid email") });
|
|
||||||
type FormData = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const { requestPasswordReset, isLoading } = useAuthStore();
|
return <ForgotPasswordView />;
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await requestPasswordReset(data.email);
|
|
||||||
setMessage("If an account exists, a reset email has been sent.");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to send reset email");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout title="Forgot password" subtitle="We'll send you a reset link if your email exists">
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{message && (
|
|
||||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">Email address</Label>
|
|
||||||
<Input
|
|
||||||
{...register("email")}
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
/>
|
|
||||||
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Sending..." : "Send reset link"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,156 +1,5 @@
|
|||||||
"use client";
|
import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
|
|
||||||
const linkWhmcsSchema = z.object({
|
|
||||||
email: z.string().email("Please enter a valid email address"),
|
|
||||||
password: z.string().min(1, "Password is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type LinkWhmcsForm = z.infer<typeof linkWhmcsSchema>;
|
|
||||||
|
|
||||||
export default function LinkWhmcsPage() {
|
export default function LinkWhmcsPage() {
|
||||||
const router = useRouter();
|
return <LinkWhmcsView />;
|
||||||
const { linkWhmcs, isLoading } = useAuthStore();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<LinkWhmcsForm>({
|
|
||||||
resolver: zodResolver(linkWhmcsSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: LinkWhmcsForm) => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
const result = await linkWhmcs(data.email, data.password);
|
|
||||||
|
|
||||||
if (result.needsPasswordSet) {
|
|
||||||
// Redirect to set password page with email
|
|
||||||
router.push(`/auth/set-password?email=${encodeURIComponent(data.email)}`);
|
|
||||||
} else {
|
|
||||||
// Should not happen with current flow, but handle just in case
|
|
||||||
router.push("/dashboard");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to link WHMCS account");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout
|
|
||||||
title="Transfer your existing account"
|
|
||||||
subtitle="Move your existing Assist Solutions account to our new portal"
|
|
||||||
>
|
|
||||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-blue-800">Welcome back, valued customer!</h3>
|
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
|
||||||
<p>
|
|
||||||
We've upgraded our portal! Use your existing account credentials below to
|
|
||||||
transfer your account and access all the new features.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">Email Address</Label>
|
|
||||||
<Input
|
|
||||||
{...register("email")}
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Your existing account email"
|
|
||||||
/>
|
|
||||||
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password">Current Password</Label>
|
|
||||||
<Input
|
|
||||||
{...register("password")}
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Your existing account password"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Use the same credentials you used to access your previous portal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Transferring account..." : "Transfer My Account"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
New to Assist Solutions?{" "}
|
|
||||||
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Create a new account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Already transferred your account?{" "}
|
|
||||||
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Sign in here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Help section */}
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-2">How does this work?</h4>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
<li>• Use your existing email and password from your previous account</li>
|
|
||||||
<li>• This is a one-time transfer to move you to our upgraded portal</li>
|
|
||||||
<li>• You'll create a new secure password for this portal after transfer</li>
|
|
||||||
<li>• All your services, billing history, and account details will be preserved</li>
|
|
||||||
<li>• Contact support if you need help accessing your existing account</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,114 +1,5 @@
|
|||||||
"use client";
|
import LoginView from "@/features/auth/views/LoginView";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
|
|
||||||
const loginSchema = z.object({
|
|
||||||
email: z.string().email("Please enter a valid email address"),
|
|
||||||
password: z.string().min(1, "Password is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type LoginForm = z.infer<typeof loginSchema>;
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
return <LoginView />;
|
||||||
const { login, isLoading } = useAuthStore();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<LoginForm>({
|
|
||||||
resolver: zodResolver(loginSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: LoginForm) => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await login(data.email, data.password);
|
|
||||||
router.push("/dashboard");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Login failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">Email address</Label>
|
|
||||||
<Input
|
|
||||||
{...register("email")}
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
/>
|
|
||||||
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
{...register("password")}
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Signing in..." : "Sign in"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Create one here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Forgot your password?{" "}
|
|
||||||
<Link href="/auth/forgot-password" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Reset it
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Had an account with us before?{" "}
|
|
||||||
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Transfer your existing account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,118 +1,5 @@
|
|||||||
"use client";
|
import ResetPasswordView from "@/features/auth/views/ResetPasswordView";
|
||||||
|
|
||||||
import { useState, Suspense } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
|
|
||||||
const schema = z
|
|
||||||
.object({
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(8, "Password must be at least 8 characters")
|
|
||||||
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/),
|
|
||||||
confirmPassword: z.string(),
|
|
||||||
})
|
|
||||||
.refine(v => v.password === v.confirmPassword, {
|
|
||||||
message: "Passwords do not match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
function ResetPasswordContent() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { resetPassword, isLoading } = useAuthStore();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const token = searchParams.get("token") || "";
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
|
||||||
if (!token) {
|
|
||||||
setError("Invalid or missing token");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await resetPassword(token, data.password);
|
|
||||||
router.push("/dashboard");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Reset failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout title="Reset your password" subtitle="Set a new password for your account">
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password">New password</Label>
|
|
||||||
<Input {...register("password")} id="password" type="password" className="mt-1" />
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
|
||||||
<Input
|
|
||||||
{...register("confirmPassword")}
|
|
||||||
id="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
{errors.confirmPassword && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Resetting..." : "Reset password"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return <ResetPasswordView />;
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<AuthLayout title="Reset your password" subtitle="Loading...">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="bg-gray-200 h-10 rounded mb-4"></div>
|
|
||||||
<div className="bg-gray-200 h-10 rounded mb-4"></div>
|
|
||||||
<div className="bg-gray-200 h-10 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ResetPasswordContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,182 +1,5 @@
|
|||||||
"use client";
|
import SetPasswordView from "@/features/auth/views/SetPasswordView";
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
|
|
||||||
const setPasswordSchema = z
|
|
||||||
.object({
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(8, "Password must be at least 8 characters")
|
|
||||||
.regex(
|
|
||||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/,
|
|
||||||
"Password must contain uppercase, lowercase, number, and special character"
|
|
||||||
),
|
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
|
||||||
})
|
|
||||||
.refine(data => data.password === data.confirmPassword, {
|
|
||||||
message: "Passwords don't match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
|
|
||||||
type SetPasswordForm = z.infer<typeof setPasswordSchema>;
|
|
||||||
|
|
||||||
function SetPasswordContent() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { setPassword, isLoading } = useAuthStore();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [email, setEmail] = useState<string>("");
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<SetPasswordForm>({
|
|
||||||
resolver: zodResolver(setPasswordSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const emailParam = searchParams.get("email");
|
|
||||||
if (emailParam) {
|
|
||||||
setEmail(emailParam);
|
|
||||||
} else {
|
|
||||||
// Redirect to link-whmcs if no email provided
|
|
||||||
router.push("/auth/link-whmcs");
|
|
||||||
}
|
|
||||||
}, [searchParams, router]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: SetPasswordForm) => {
|
|
||||||
if (!email) {
|
|
||||||
setError("Email is required. Please start the linking process again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await setPassword(email, data.password);
|
|
||||||
router.push("/dashboard");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to set password");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
return null; // Will redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout
|
|
||||||
title="Create your new portal password"
|
|
||||||
subtitle="Complete your account transfer with a secure password"
|
|
||||||
>
|
|
||||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-green-800">Account transfer successful!</h3>
|
|
||||||
<div className="mt-2 text-sm text-green-700">
|
|
||||||
<p>
|
|
||||||
Great! Your account <strong>{email}</strong> has been transferred. Now create a
|
|
||||||
secure password for your upgraded portal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password">New Portal Password</Label>
|
|
||||||
<Input
|
|
||||||
{...register("password")}
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Create a secure password"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Must be at least 8 characters with uppercase, lowercase, number, and special character
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
|
||||||
<Input
|
|
||||||
{...register("confirmPassword")}
|
|
||||||
id="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
/>
|
|
||||||
{errors.confirmPassword && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Completing transfer..." : "Complete Account Transfer"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Security info */}
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-2">What happens next?</h4>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
<li>• This new password will be used for all future logins</li>
|
|
||||||
<li>• All your services, billing history, and account data are preserved</li>
|
|
||||||
<li>• You'll have access to enhanced features in this upgraded portal</li>
|
|
||||||
<li>• Your old login credentials will no longer be needed</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SetPasswordPage() {
|
export default function SetPasswordPage() {
|
||||||
return (
|
return <SetPasswordView />;
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<AuthLayout title="Set Password" subtitle="Complete your account setup">
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SetPasswordContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,769 +1,5 @@
|
|||||||
"use client";
|
import SignupView from "@/features/auth/views/SignupView";
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AuthLayout } from "@/components/auth/auth-layout";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
|
||||||
import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from "lucide-react";
|
|
||||||
|
|
||||||
// Step 1: Customer Number Validation Schema
|
|
||||||
const step1Schema = z.object({
|
|
||||||
sfNumber: z.string().min(1, "Customer Number is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 2: Personal Information Schema
|
|
||||||
const step2Schema = z
|
|
||||||
.object({
|
|
||||||
firstName: z.string().min(1, "First name is required"),
|
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
|
||||||
email: z.string().email("Please enter a valid email address"),
|
|
||||||
confirmEmail: z.string().email("Please confirm with a valid email"),
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(8, "Password must be at least 8 characters")
|
|
||||||
.regex(
|
|
||||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/,
|
|
||||||
"Password must contain uppercase, lowercase, number, and special character"
|
|
||||||
),
|
|
||||||
confirmPassword: z.string(),
|
|
||||||
})
|
|
||||||
.refine(values => values.email === values.confirmEmail, {
|
|
||||||
message: "Emails do not match",
|
|
||||||
path: ["confirmEmail"],
|
|
||||||
})
|
|
||||||
.refine(values => values.password === values.confirmPassword, {
|
|
||||||
message: "Passwords do not match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Contact & Address Schema
|
|
||||||
const step3Schema = z.object({
|
|
||||||
company: z.string().optional(),
|
|
||||||
phone: z.string().min(1, "Phone number is required"),
|
|
||||||
addressLine1: z.string().min(1, "Address is required"),
|
|
||||||
addressLine2: z.string().optional(),
|
|
||||||
city: z.string().min(1, "City is required"),
|
|
||||||
state: z.string().min(1, "State/Prefecture is required"),
|
|
||||||
postalCode: z.string().min(1, "Postal code is required"),
|
|
||||||
country: z
|
|
||||||
.string()
|
|
||||||
.min(2, "Please select a valid country")
|
|
||||||
.max(2, "Please select a valid country"),
|
|
||||||
nationality: z.string().optional(),
|
|
||||||
dateOfBirth: z.string().optional(),
|
|
||||||
gender: z.enum(["male", "female", "other"]).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type Step1Form = z.infer<typeof step1Schema>;
|
|
||||||
type Step2Form = z.infer<typeof step2Schema>;
|
|
||||||
type Step3Form = z.infer<typeof step3Schema>;
|
|
||||||
|
|
||||||
interface SignupData {
|
|
||||||
sfNumber: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
company?: string;
|
|
||||||
phone: string;
|
|
||||||
addressLine1: string;
|
|
||||||
addressLine2?: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postalCode: string;
|
|
||||||
country: string;
|
|
||||||
nationality?: string;
|
|
||||||
dateOfBirth?: string;
|
|
||||||
gender?: "male" | "female" | "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const router = useRouter();
|
return <SignupView />;
|
||||||
const { signup, isLoading, checkPasswordNeeded } = useAuthStore();
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [validationStatus, setValidationStatus] = useState<{
|
|
||||||
sfNumberValid: boolean;
|
|
||||||
whAccountValid: boolean;
|
|
||||||
sfAccountId?: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [emailCheckStatus, setEmailCheckStatus] = useState<{
|
|
||||||
userExists: boolean;
|
|
||||||
needsPasswordSet: boolean;
|
|
||||||
showActions: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
const emailCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Step 1 Form
|
|
||||||
const step1Form = useForm<Step1Form>({
|
|
||||||
resolver: zodResolver(step1Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 2 Form
|
|
||||||
const step2Form = useForm<Step2Form>({
|
|
||||||
resolver: zodResolver(step2Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3 Form
|
|
||||||
const step3Form = useForm<Step3Form>({
|
|
||||||
resolver: zodResolver(step3Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Validate Customer Number
|
|
||||||
const onStep1Submit = async (data: Step1Form) => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
setValidationStatus(null);
|
|
||||||
|
|
||||||
// Call backend to validate SF number and WH Account field
|
|
||||||
const response = await fetch("/api/auth/validate-signup", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ sfNumber: data.sfNumber }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ValidateSignupResponse = {
|
|
||||||
message?: string;
|
|
||||||
sfAccountId?: string;
|
|
||||||
sfNumberValid?: boolean;
|
|
||||||
whAccountValid?: boolean;
|
|
||||||
};
|
|
||||||
const resultRaw: unknown = await response.json();
|
|
||||||
const result = resultRaw as ValidateSignupResponse;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 409) {
|
|
||||||
// User already has account
|
|
||||||
setError(
|
|
||||||
"You already have an account. Please use the login page to access your existing account."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(result.message || "Validation failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationStatus({
|
|
||||||
sfNumberValid: true,
|
|
||||||
whAccountValid: true,
|
|
||||||
sfAccountId: result.sfAccountId,
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentStep(2);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Validation failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check email when user enters it (debounced)
|
|
||||||
const handleEmailCheck = useCallback(
|
|
||||||
async (email: string) => {
|
|
||||||
if (!email || !email.includes("@")) {
|
|
||||||
setEmailCheckStatus(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await checkPasswordNeeded(email);
|
|
||||||
setEmailCheckStatus({
|
|
||||||
userExists: result.userExists,
|
|
||||||
needsPasswordSet: result.needsPasswordSet,
|
|
||||||
showActions: result.userExists,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Silently fail email check - don't block the flow
|
|
||||||
setEmailCheckStatus(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[checkPasswordNeeded]
|
|
||||||
);
|
|
||||||
|
|
||||||
const debouncedEmailCheck = useCallback(
|
|
||||||
(email: string) => {
|
|
||||||
if (emailCheckTimeoutRef.current) {
|
|
||||||
clearTimeout(emailCheckTimeoutRef.current);
|
|
||||||
}
|
|
||||||
emailCheckTimeoutRef.current = setTimeout(() => {
|
|
||||||
void handleEmailCheck(email);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
[handleEmailCheck]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 2: Personal Information
|
|
||||||
const onStep2Submit = () => {
|
|
||||||
setCurrentStep(3);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 3: Contact & Address
|
|
||||||
const onStep3Submit = async (data: Step3Form) => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
console.log("Step 3 form data:", data);
|
|
||||||
console.log("Step 1 data:", step1Form.getValues());
|
|
||||||
console.log("Step 2 data:", step2Form.getValues());
|
|
||||||
|
|
||||||
const signupData: SignupData = {
|
|
||||||
sfNumber: step1Form.getValues("sfNumber"),
|
|
||||||
firstName: step2Form.getValues("firstName"),
|
|
||||||
lastName: step2Form.getValues("lastName"),
|
|
||||||
email: step2Form.getValues("email"),
|
|
||||||
password: step2Form.getValues("password"),
|
|
||||||
company: data.company,
|
|
||||||
phone: data.phone,
|
|
||||||
addressLine1: data.addressLine1,
|
|
||||||
addressLine2: data.addressLine2,
|
|
||||||
city: data.city,
|
|
||||||
state: data.state,
|
|
||||||
postalCode: data.postalCode,
|
|
||||||
country: data.country,
|
|
||||||
nationality: data.nationality,
|
|
||||||
dateOfBirth: data.dateOfBirth,
|
|
||||||
gender: data.gender,
|
|
||||||
};
|
|
||||||
|
|
||||||
await signup({
|
|
||||||
email: signupData.email,
|
|
||||||
password: signupData.password,
|
|
||||||
firstName: signupData.firstName,
|
|
||||||
lastName: signupData.lastName,
|
|
||||||
company: signupData.company,
|
|
||||||
phone: signupData.phone,
|
|
||||||
sfNumber: signupData.sfNumber,
|
|
||||||
address: {
|
|
||||||
line1: signupData.addressLine1,
|
|
||||||
line2: signupData.addressLine2,
|
|
||||||
city: signupData.city,
|
|
||||||
state: signupData.state,
|
|
||||||
postalCode: signupData.postalCode,
|
|
||||||
country: signupData.country,
|
|
||||||
},
|
|
||||||
nationality: signupData.nationality,
|
|
||||||
dateOfBirth: signupData.dateOfBirth,
|
|
||||||
gender: signupData.gender,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/dashboard");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Signup failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
if (currentStep > 1) {
|
|
||||||
setCurrentStep(currentStep - 1);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStepIndicator = () => (
|
|
||||||
<div className="flex items-center justify-center mb-8">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{[1, 2, 3].map(step => (
|
|
||||||
<div key={step} className="flex items-center">
|
|
||||||
<div
|
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
|
||||||
currentStep >= step ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</div>
|
|
||||||
{step < 3 && (
|
|
||||||
<div
|
|
||||||
className={`w-12 h-0.5 mx-2 ${currentStep > step ? "bg-blue-600" : "bg-gray-200"}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStep1 = () => (
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void step1Form.handleSubmit(onStep1Submit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="sfNumber">Customer Number (SF Number)</Label>
|
|
||||||
<Input
|
|
||||||
{...step1Form.register("sfNumber")}
|
|
||||||
id="sfNumber"
|
|
||||||
type="text"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Enter your customer number"
|
|
||||||
/>
|
|
||||||
{step1Form.formState.errors.sfNumber && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{step1Form.formState.errors.sfNumber.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{validationStatus && (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
||||||
<span className="text-green-800 font-medium">
|
|
||||||
Customer number validated successfully
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-green-700 text-sm mt-1">
|
|
||||||
Your customer number has been verified and is eligible for account creation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? "Validating..." : "Continue"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStep2 = () => (
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
void step2Form.handleSubmit(onStep2Submit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="firstName">First name</Label>
|
|
||||||
<Input
|
|
||||||
{...step2Form.register("firstName")}
|
|
||||||
id="firstName"
|
|
||||||
type="text"
|
|
||||||
autoComplete="given-name"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="John"
|
|
||||||
/>
|
|
||||||
{step2Form.formState.errors.firstName && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step2Form.formState.errors.firstName.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="lastName">Last name</Label>
|
|
||||||
<Input
|
|
||||||
{...step2Form.register("lastName")}
|
|
||||||
id="lastName"
|
|
||||||
type="text"
|
|
||||||
autoComplete="family-name"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Doe"
|
|
||||||
/>
|
|
||||||
{step2Form.formState.errors.lastName && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step2Form.formState.errors.lastName.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">Email address</Label>
|
|
||||||
<Input
|
|
||||||
{...step2Form.register("email", {
|
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const email = e.target.value;
|
|
||||||
step2Form.setValue("email", email);
|
|
||||||
debouncedEmailCheck(email);
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="john@example.com"
|
|
||||||
/>
|
|
||||||
{step2Form.formState.errors.email && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{step2Form.formState.errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Email Check Status */}
|
|
||||||
{emailCheckStatus?.showActions && (
|
|
||||||
<div className="mt-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="text-sm font-medium text-blue-800">
|
|
||||||
We found an existing account with this email
|
|
||||||
</h4>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{emailCheckStatus.needsPasswordSet ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-blue-700 mb-2">
|
|
||||||
You need to set a password for your account.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/auth/set-password"
|
|
||||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Set Password
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-blue-700 mb-2">
|
|
||||||
Please sign in to your existing account.
|
|
||||||
</p>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Link
|
|
||||||
href="/auth/login"
|
|
||||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/auth/forgot-password"
|
|
||||||
className="inline-flex items-center px-3 py-2 border border-blue-300 text-sm leading-4 font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50"
|
|
||||||
>
|
|
||||||
Forgot Password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="confirmEmail">Email address (confirm)</Label>
|
|
||||||
<Input
|
|
||||||
{...step2Form.register("confirmEmail")}
|
|
||||||
id="confirmEmail"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="john@example.com"
|
|
||||||
/>
|
|
||||||
{step2Form.formState.errors.confirmEmail && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step2Form.formState.errors.confirmEmail.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
{...step2Form.register("password")}
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Create a secure password"
|
|
||||||
/>
|
|
||||||
{step2Form.formState.errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step2Form.formState.errors.password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Must be at least 8 characters with uppercase, lowercase, number, and special character
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="confirmPassword">Password (confirm)</Label>
|
|
||||||
<Input
|
|
||||||
{...step2Form.register("confirmPassword")}
|
|
||||||
id="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Re-enter your password"
|
|
||||||
/>
|
|
||||||
{step2Form.formState.errors.confirmPassword && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step2Form.formState.errors.confirmPassword.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Button type="button" variant="outline" onClick={goBack} className="flex-1">
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" className="flex-1">
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStep3 = () => (
|
|
||||||
<form
|
|
||||||
onSubmit={e => {
|
|
||||||
console.log("Step 3 form submit triggered");
|
|
||||||
console.log("Form errors:", step3Form.formState.errors);
|
|
||||||
void step3Form.handleSubmit(onStep3Submit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="company">Company (optional)</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("company")}
|
|
||||||
id="company"
|
|
||||||
type="text"
|
|
||||||
autoComplete="organization"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Acme Corp"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="phone">Phone number</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("phone")}
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
autoComplete="tel"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="+81 90 1234 5678"
|
|
||||||
/>
|
|
||||||
{step3Form.formState.errors.phone && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{step3Form.formState.errors.phone.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="addressLine1">Address Line 1</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("addressLine1")}
|
|
||||||
id="addressLine1"
|
|
||||||
type="text"
|
|
||||||
autoComplete="address-line1"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Street, number"
|
|
||||||
/>
|
|
||||||
{step3Form.formState.errors.addressLine1 && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step3Form.formState.errors.addressLine1.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="addressLine2">Address Line 2 (optional)</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("addressLine2")}
|
|
||||||
id="addressLine2"
|
|
||||||
type="text"
|
|
||||||
autoComplete="address-line2"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Apartment, suite, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="city">City</Label>
|
|
||||||
<Input {...step3Form.register("city")} id="city" type="text" className="mt-1" />
|
|
||||||
{step3Form.formState.errors.city && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{step3Form.formState.errors.city.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="state">State/Prefecture</Label>
|
|
||||||
<Input {...step3Form.register("state")} id="state" type="text" className="mt-1" />
|
|
||||||
{step3Form.formState.errors.state && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{step3Form.formState.errors.state.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="postalCode">Postal Code</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("postalCode")}
|
|
||||||
id="postalCode"
|
|
||||||
type="text"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
{step3Form.formState.errors.postalCode && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step3Form.formState.errors.postalCode.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="country">Country</Label>
|
|
||||||
<select
|
|
||||||
{...step3Form.register("country")}
|
|
||||||
id="country"
|
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<option value="DE">Germany</option>
|
|
||||||
<option value="FR">France</option>
|
|
||||||
<option value="IT">Italy</option>
|
|
||||||
<option value="ES">Spain</option>
|
|
||||||
<option value="NL">Netherlands</option>
|
|
||||||
<option value="SE">Sweden</option>
|
|
||||||
<option value="NO">Norway</option>
|
|
||||||
<option value="DK">Denmark</option>
|
|
||||||
<option value="FI">Finland</option>
|
|
||||||
<option value="CH">Switzerland</option>
|
|
||||||
<option value="AT">Austria</option>
|
|
||||||
<option value="BE">Belgium</option>
|
|
||||||
<option value="IE">Ireland</option>
|
|
||||||
<option value="PT">Portugal</option>
|
|
||||||
<option value="GR">Greece</option>
|
|
||||||
<option value="PL">Poland</option>
|
|
||||||
<option value="CZ">Czech Republic</option>
|
|
||||||
<option value="HU">Hungary</option>
|
|
||||||
<option value="SK">Slovakia</option>
|
|
||||||
<option value="SI">Slovenia</option>
|
|
||||||
<option value="HR">Croatia</option>
|
|
||||||
<option value="BG">Bulgaria</option>
|
|
||||||
<option value="RO">Romania</option>
|
|
||||||
<option value="LT">Lithuania</option>
|
|
||||||
<option value="LV">Latvia</option>
|
|
||||||
<option value="EE">Estonia</option>
|
|
||||||
<option value="MT">Malta</option>
|
|
||||||
<option value="CY">Cyprus</option>
|
|
||||||
<option value="LU">Luxembourg</option>
|
|
||||||
</select>
|
|
||||||
{step3Form.formState.errors.country && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{step3Form.formState.errors.country.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="nationality">Nationality (optional)</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("nationality")}
|
|
||||||
id="nationality"
|
|
||||||
type="text"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="dateOfBirth">Date of Birth (optional)</Label>
|
|
||||||
<Input
|
|
||||||
{...step3Form.register("dateOfBirth")}
|
|
||||||
id="dateOfBirth"
|
|
||||||
type="date"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="gender">Gender (optional)</Label>
|
|
||||||
<select
|
|
||||||
{...step3Form.register("gender")}
|
|
||||||
id="gender"
|
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
|
|
||||||
>
|
|
||||||
<option value="">Select</option>
|
|
||||||
<option value="male">Male</option>
|
|
||||||
<option value="female">Female</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Button type="button" variant="outline" onClick={goBack} className="flex-1">
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => console.log("Create account button clicked")}
|
|
||||||
>
|
|
||||||
{isLoading ? "Creating account..." : "Create account"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthLayout
|
|
||||||
title="Create your account"
|
|
||||||
subtitle="Join Assist Solutions and manage your services"
|
|
||||||
>
|
|
||||||
{renderStepIndicator()}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<XCircle className="w-5 h-5" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
{error.includes("already have an account") && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500 underline">
|
|
||||||
Go to login page
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 1 && renderStep1()}
|
|
||||||
{currentStep === 2 && renderStep2()}
|
|
||||||
{currentStep === 3 && renderStep3()}
|
|
||||||
|
|
||||||
<div className="text-center space-y-2 mt-8">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Sign in here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Already a customer?{" "}
|
|
||||||
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">
|
|
||||||
Transfer your existing account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { PageLayout } from "@/components/layout/PageLayout";
|
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
|
||||||
import { useProfileData } from "../hooks/useProfileData";
|
|
||||||
import { PersonalInfoCard } from "../components/PersonalInfoCard";
|
|
||||||
import { AddressCard } from "../components/AddressCard";
|
|
||||||
import { PasswordChangeCard } from "../components/PasswordChangeCard";
|
|
||||||
|
|
||||||
export function ProfileContainer() {
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
const {
|
|
||||||
// loading,
|
|
||||||
error,
|
|
||||||
// billingInfo,
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
addressData,
|
|
||||||
setAddressData,
|
|
||||||
saveProfile,
|
|
||||||
saveAddress,
|
|
||||||
isSavingProfile,
|
|
||||||
isSavingAddress,
|
|
||||||
} = useProfileData();
|
|
||||||
|
|
||||||
const [isEditingInfo, setIsEditingInfo] = useState(false);
|
|
||||||
const [isEditingAddress, setIsEditingAddress] = useState(false);
|
|
||||||
|
|
||||||
const [pwdError, setPwdError] = useState<string | null>(null);
|
|
||||||
const [pwdSuccess, setPwdSuccess] = useState<string | null>(null);
|
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
|
||||||
const [pwdForm, setPwdForm] = useState({
|
|
||||||
currentPassword: "",
|
|
||||||
newPassword: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
setIsChangingPassword(true);
|
|
||||||
setPwdError(null);
|
|
||||||
setPwdSuccess(null);
|
|
||||||
try {
|
|
||||||
if (!pwdForm.currentPassword || !pwdForm.newPassword) {
|
|
||||||
setPwdError("Please fill in all password fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
|
|
||||||
setPwdError("New password and confirmation do not match");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await useAuthStore.getState().changePassword({
|
|
||||||
currentPassword: pwdForm.currentPassword,
|
|
||||||
newPassword: pwdForm.newPassword,
|
|
||||||
confirmPassword: pwdForm.newPassword,
|
|
||||||
});
|
|
||||||
setPwdSuccess("Password changed successfully.");
|
|
||||||
setPwdForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
|
||||||
} catch (err) {
|
|
||||||
setPwdError(err instanceof Error ? err.message : "Failed to change password");
|
|
||||||
} finally {
|
|
||||||
setIsChangingPassword(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout
|
|
||||||
title={user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Profile"}
|
|
||||||
description="Manage your personal information and address"
|
|
||||||
icon={<></>}
|
|
||||||
>
|
|
||||||
<div className="space-y-8">
|
|
||||||
<PersonalInfoCard
|
|
||||||
data={formData}
|
|
||||||
isEditing={isEditingInfo}
|
|
||||||
isSaving={isSavingProfile}
|
|
||||||
onEdit={() => setIsEditingInfo(true)}
|
|
||||||
onCancel={() => setIsEditingInfo(false)}
|
|
||||||
onChange={(field, value) => setFormData(prev => ({ ...prev, [field]: value }))}
|
|
||||||
onSave={() => {
|
|
||||||
void saveProfile(formData).then(ok => {
|
|
||||||
if (ok) setIsEditingInfo(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddressCard
|
|
||||||
address={addressData}
|
|
||||||
isEditing={isEditingAddress}
|
|
||||||
isSaving={isSavingAddress}
|
|
||||||
error={error}
|
|
||||||
onEdit={() => setIsEditingAddress(true)}
|
|
||||||
onCancel={() => setIsEditingAddress(false)}
|
|
||||||
onSave={() => {
|
|
||||||
void saveAddress(addressData).then(ok => {
|
|
||||||
if (ok) setIsEditingAddress(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onAddressChange={addr => setAddressData(addr)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordChangeCard
|
|
||||||
isChanging={isChangingPassword}
|
|
||||||
error={pwdError}
|
|
||||||
success={pwdSuccess}
|
|
||||||
form={pwdForm}
|
|
||||||
setForm={next => setPwdForm(prev => ({ ...prev, ...next }))}
|
|
||||||
onSubmit={() => {
|
|
||||||
void handleChangePassword();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,388 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { LoadingCard, Skeleton } from "@/components/ui/loading-skeleton";
|
|
||||||
import { AlertBanner } from "@/components/common/AlertBanner";
|
|
||||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon, UserIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
|
||||||
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
|
|
||||||
import { AddressForm } from "@/features/catalog/components/base/AddressForm";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useAddressEdit } from "@/features/account/hooks/useAddressEdit";
|
|
||||||
|
|
||||||
export default function ProfileContainer() {
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [editingProfile, setEditingProfile] = useState(false);
|
|
||||||
const [editingAddress, setEditingAddress] = useState(false);
|
|
||||||
|
|
||||||
const profile = useProfileEdit({
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const address = useAddressEdit({
|
|
||||||
street: "",
|
|
||||||
streetLine2: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postalCode: "",
|
|
||||||
country: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [addr, prof] = await Promise.all([
|
|
||||||
accountService.getAddress().catch(() => null),
|
|
||||||
accountService.getProfile().catch(() => null),
|
|
||||||
]);
|
|
||||||
if (addr) {
|
|
||||||
address.setForm({
|
|
||||||
street: addr.street ?? "",
|
|
||||||
streetLine2: addr.streetLine2 ?? "",
|
|
||||||
city: addr.city ?? "",
|
|
||||||
state: addr.state ?? "",
|
|
||||||
postalCode: addr.postalCode ?? "",
|
|
||||||
country: addr.country ?? "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (prof) {
|
|
||||||
// Update local form with authoritative profile data
|
|
||||||
profile.setForm({
|
|
||||||
firstName: prof.firstName || "",
|
|
||||||
lastName: prof.lastName || "",
|
|
||||||
phone: prof.phone || "",
|
|
||||||
});
|
|
||||||
// Also hydrate auth store user so static fields render correctly
|
|
||||||
useAuthStore.setState(state => ({
|
|
||||||
...state,
|
|
||||||
user: state.user
|
|
||||||
? {
|
|
||||||
...state.user,
|
|
||||||
firstName: prof.firstName || state.user.firstName,
|
|
||||||
lastName: prof.lastName || state.user.lastName,
|
|
||||||
phone: prof.phone || state.user.phone,
|
|
||||||
}
|
|
||||||
: (prof as unknown as typeof state.user),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load profile data");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [user?.id]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="py-6">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8 space-y-8">
|
|
||||||
{/* Personal Information Card */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="h-6 w-6 bg-blue-200 rounded" />
|
|
||||||
<div className="h-6 w-40 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-20 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Skeleton className="h-4 w-28 mb-3" />
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
<Skeleton className="h-5 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-64 mt-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200 mt-6">
|
|
||||||
<Skeleton className="h-9 w-24" />
|
|
||||||
<Skeleton className="h-9 w-28" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address Card */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="h-6 w-6 bg-blue-200 rounded" />
|
|
||||||
<div className="h-6 w-48 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-20 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-60" />
|
|
||||||
<Skeleton className="h-4 w-48" />
|
|
||||||
<Skeleton className="h-4 w-52" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6">
|
|
||||||
<Skeleton className="h-9 w-24" />
|
|
||||||
<Skeleton className="h-9 w-28" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-6">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
|
||||||
{error && (
|
|
||||||
<AlertBanner variant="error" title="Error" className="mb-6">
|
|
||||||
{error}
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Personal Information */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<UserIcon className="h-6 w-6 text-blue-600" />
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
|
|
||||||
</div>
|
|
||||||
{!editingProfile && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingProfile(true)}>
|
|
||||||
<PencilIcon className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
|
|
||||||
{editingProfile ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={profile.form.firstName}
|
|
||||||
onChange={e => profile.setField("firstName", e.target.value)}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-900 py-2">
|
|
||||||
{user?.firstName || <span className="text-gray-500 italic">Not provided</span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Last Name</label>
|
|
||||||
{editingProfile ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={profile.form.lastName}
|
|
||||||
onChange={e => profile.setField("lastName", e.target.value)}
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-900 py-2">
|
|
||||||
{user?.lastName || <span className="text-gray-500 italic">Not provided</span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-base text-gray-900 font-medium">{user?.email}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Email cannot be changed from the portal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Phone Number</label>
|
|
||||||
{editingProfile ? (
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={profile.form.phone}
|
|
||||||
onChange={e => profile.setField("phone", e.target.value)}
|
|
||||||
placeholder="+81 XX-XXXX-XXXX"
|
|
||||||
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-900 py-2">
|
|
||||||
{user?.phone || <span className="text-gray-500 italic">Not provided</span>}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editingProfile && (
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200 mt-6">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingProfile(false)}
|
|
||||||
disabled={profile.saving}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-4 w-4 mr-1" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void profile.save().then(ok => {
|
|
||||||
if (ok) setEditingProfile(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={profile.saving}
|
|
||||||
>
|
|
||||||
{profile.saving ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4 mr-1" />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div className="bg-white shadow-sm rounded-xl border border-gray-200 mt-8">
|
|
||||||
<div className="px-6 py-5 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<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">Address Information</h2>
|
|
||||||
</div>
|
|
||||||
{!editingAddress && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditingAddress(true)}>
|
|
||||||
<PencilIcon className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{editingAddress ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<AddressForm
|
|
||||||
initialAddress={{
|
|
||||||
street: address.form.street,
|
|
||||||
streetLine2: address.form.streetLine2,
|
|
||||||
city: address.form.city,
|
|
||||||
state: address.form.state,
|
|
||||||
postalCode: address.form.postalCode,
|
|
||||||
country: address.form.country,
|
|
||||||
}}
|
|
||||||
onChange={a =>
|
|
||||||
address.setForm({
|
|
||||||
street: a.street,
|
|
||||||
streetLine2: a.streetLine2,
|
|
||||||
city: a.city,
|
|
||||||
state: a.state,
|
|
||||||
postalCode: a.postalCode,
|
|
||||||
country: a.country,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
title="Mailing Address"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingAddress(false)}
|
|
||||||
disabled={address.saving}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-4 w-4 mr-2" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void address.save().then(ok => {
|
|
||||||
if (ok) setEditingAddress(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={address.saving}
|
|
||||||
>
|
|
||||||
{address.saving ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4 mr-2" />
|
|
||||||
Save Address
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{address.error && (
|
|
||||||
<AlertBanner variant="error" title="Address Error">
|
|
||||||
{address.error}
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{address.form.street || address.form.city ? (
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<div className="text-gray-900 space-y-1">
|
|
||||||
{address.form.street && <p className="font-medium">{address.form.street}</p>}
|
|
||||||
{address.form.streetLine2 && <p>{address.form.streetLine2}</p>}
|
|
||||||
<p>
|
|
||||||
{[address.form.city, address.form.state, address.form.postalCode]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</p>
|
|
||||||
<p>{address.form.country}</p>
|
|
||||||
</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={() => setEditingAddress(true)}>Add Address</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Button, Input, ErrorMessage } from "@/components/ui";
|
||||||
|
import { FormField } from "@/components/common/FormField";
|
||||||
|
import { useWhmcsLink } from "@/features/auth/hooks";
|
||||||
|
import { z } from "@customer-portal/domain";
|
||||||
|
|
||||||
|
const linkSchema = z.object({
|
||||||
|
email: z.string().email("Please enter a valid email address"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LinkWhmcsFormData = z.infer<typeof linkSchema>;
|
||||||
|
|
||||||
|
interface LinkWhmcsFormProps {
|
||||||
|
onTransferred?: (result: { needsPasswordSet: boolean; email: string }) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
||||||
|
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<LinkWhmcsFormData>({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof LinkWhmcsFormData | "general", string>>>({});
|
||||||
|
const [touched, setTouched] = useState<Record<keyof LinkWhmcsFormData, boolean>>({
|
||||||
|
email: false,
|
||||||
|
password: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateField = useCallback(
|
||||||
|
(field: keyof LinkWhmcsFormData, value: string) => {
|
||||||
|
try {
|
||||||
|
linkSchema.shape[field].parse(value);
|
||||||
|
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
const message = err.issues[0]?.message ?? "Invalid value";
|
||||||
|
setErrors(prev => ({ ...prev, [field]: message }));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateForm = useCallback(() => {
|
||||||
|
const result = linkSchema.safeParse(formData);
|
||||||
|
if (result.success) {
|
||||||
|
setErrors(prev => ({ ...prev, email: undefined, password: undefined, general: undefined }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldErrors: Partial<Record<keyof LinkWhmcsFormData, string>> = {};
|
||||||
|
result.error.issues.forEach(issue => {
|
||||||
|
const field = issue.path[0];
|
||||||
|
if (field === "email" || field === "password") {
|
||||||
|
fieldErrors[field] = issue.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setErrors(prev => ({ ...prev, ...fieldErrors }));
|
||||||
|
return false;
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(field: keyof LinkWhmcsFormData, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
if (errors.general) {
|
||||||
|
setErrors(prev => ({ ...prev, general: undefined }));
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touched[field]) {
|
||||||
|
setTimeout(() => {
|
||||||
|
validateField(field, value);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[errors.general, touched, validateField, clearError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFieldBlur = useCallback(
|
||||||
|
(field: keyof LinkWhmcsFormData) => {
|
||||||
|
setTouched(prev => ({ ...prev, [field]: true }));
|
||||||
|
validateField(field, formData[field]);
|
||||||
|
},
|
||||||
|
[formData, validateField]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setTouched({ email: true, password: true });
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await linkWhmcs({
|
||||||
|
email: formData.email.trim(),
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
onTransferred?.({ needsPasswordSet: result.needsPasswordSet, email: formData.email.trim() });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to transfer account";
|
||||||
|
setErrors(prev => ({ ...prev, general: message }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[formData, validateForm, linkWhmcs, onTransferred]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFormValid =
|
||||||
|
!errors.email &&
|
||||||
|
!errors.password &&
|
||||||
|
formData.email.length > 0 &&
|
||||||
|
formData.password.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={e => {
|
||||||
|
void handleSubmit(e);
|
||||||
|
}}
|
||||||
|
className={`space-y-6 ${className}`}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
{(errors.general || error) && (
|
||||||
|
<ErrorMessage variant="default" className="text-center">
|
||||||
|
{errors.general || error}
|
||||||
|
</ErrorMessage>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Email address" error={errors.email} required>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={e => handleFieldChange("email", e.target.value)}
|
||||||
|
onBlur={() => handleFieldBlur("email")}
|
||||||
|
placeholder="Your existing account email"
|
||||||
|
disabled={loading}
|
||||||
|
error={errors.email}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Current password" error={errors.password} required>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => handleFieldChange("password", e.target.value)}
|
||||||
|
onBlur={() => handleFieldBlur("password")}
|
||||||
|
placeholder="Your existing account password"
|
||||||
|
disabled={loading}
|
||||||
|
error={errors.password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Use the same credentials you used to access your previous portal.
|
||||||
|
</p>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
disabled={loading || !isFormValid}
|
||||||
|
loading={loading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{loading ? "Transferring account..." : "Transfer my account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkWhmcsForm;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { LinkWhmcsForm } from "./LinkWhmcsForm";
|
||||||
@ -7,3 +7,4 @@ export { LoginForm } from "./LoginForm";
|
|||||||
export { SignupForm } from "./SignupForm";
|
export { SignupForm } from "./SignupForm";
|
||||||
export { PasswordResetForm } from "./PasswordResetForm";
|
export { PasswordResetForm } from "./PasswordResetForm";
|
||||||
export { SetPasswordForm } from "./SetPasswordForm";
|
export { SetPasswordForm } from "./SetPasswordForm";
|
||||||
|
export { LinkWhmcsForm } from "./LinkWhmcsForm";
|
||||||
|
|||||||
17
apps/portal/src/features/auth/views/ForgotPasswordView.tsx
Normal file
17
apps/portal/src/features/auth/views/ForgotPasswordView.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { PasswordResetForm } from "@/features/auth/components";
|
||||||
|
|
||||||
|
export function ForgotPasswordView() {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Forgot password"
|
||||||
|
subtitle="Enter your email address and we'll send you a reset link"
|
||||||
|
>
|
||||||
|
<PasswordResetForm mode="request" />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ForgotPasswordView;
|
||||||
81
apps/portal/src/features/auth/views/LinkWhmcsView.tsx
Normal file
81
apps/portal/src/features/auth/views/LinkWhmcsView.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { LinkWhmcsForm } from "@/features/auth/components";
|
||||||
|
|
||||||
|
export function LinkWhmcsView() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Transfer your existing account"
|
||||||
|
subtitle="Move your existing Assist Solutions account to our new portal"
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm text-blue-700 space-y-2">
|
||||||
|
<p>
|
||||||
|
We've upgraded our customer portal. Use your existing Assist Solutions credentials
|
||||||
|
to transfer your account and gain access to the new experience.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>All of your services and billing history will come with you</li>
|
||||||
|
<li>We'll guide you through creating a new, secure password afterwards</li>
|
||||||
|
<li>Your previous login credentials will no longer be needed once you transfer</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkWhmcsForm
|
||||||
|
onTransferred={({ needsPasswordSet, email }) => {
|
||||||
|
if (needsPasswordSet) {
|
||||||
|
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
Need a new account?{" "}
|
||||||
|
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-500">
|
||||||
|
Create one here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Already transferred your account?{" "}
|
||||||
|
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
|
||||||
|
Sign in here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-gray-200 space-y-2 text-sm text-gray-600">
|
||||||
|
<h3 className="font-medium text-gray-900">How the transfer works</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>Enter the email and password you use for the legacy portal</li>
|
||||||
|
<li>We verify your account and ask you to set a new secure password</li>
|
||||||
|
<li>All existing subscriptions, invoices, and tickets stay connected</li>
|
||||||
|
<li>Need help? Contact support and we'll guide you through it</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkWhmcsView;
|
||||||
14
apps/portal/src/features/auth/views/LoginView.tsx
Normal file
14
apps/portal/src/features/auth/views/LoginView.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { LoginForm } from "@/features/auth/components";
|
||||||
|
|
||||||
|
export function LoginView() {
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
|
||||||
|
<LoginForm />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginView;
|
||||||
56
apps/portal/src/features/auth/views/ResetPasswordView.tsx
Normal file
56
apps/portal/src/features/auth/views/ResetPasswordView.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { PasswordResetForm } from "@/features/auth/components";
|
||||||
|
|
||||||
|
function ResetPasswordContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Reset your password" subtitle="We couldn't validate your reset link">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
The password reset link is missing or has expired. Please request a new link to continue.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/forgot-password"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||||
|
>
|
||||||
|
Request new reset link
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Reset your password" subtitle="Set a new password for your account">
|
||||||
|
<PasswordResetForm mode="reset" token={token} />
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResetPasswordView() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<AuthLayout title="Reset your password" subtitle="Loading your reset details...">
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
<div className="h-10 rounded bg-gray-200" />
|
||||||
|
<div className="h-10 rounded bg-gray-200" />
|
||||||
|
<div className="h-10 rounded bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResetPasswordContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResetPasswordView;
|
||||||
96
apps/portal/src/features/auth/views/SetPasswordView.tsx
Normal file
96
apps/portal/src/features/auth/views/SetPasswordView.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { SetPasswordForm } from "@/features/auth/components";
|
||||||
|
|
||||||
|
function SetPasswordContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const email = searchParams.get("email") ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!email) {
|
||||||
|
router.replace("/auth/link-whmcs");
|
||||||
|
}
|
||||||
|
}, [email, router]);
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Set password" subtitle="Redirecting you to the account transfer flow">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
We couldn't find the email associated with your transfer. Please start the process
|
||||||
|
again so we can verify your account.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/link-whmcs"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||||
|
>
|
||||||
|
Go to account transfer
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Create your new portal password"
|
||||||
|
subtitle="Complete your account transfer with a secure password"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-green-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm text-green-800">
|
||||||
|
<p>
|
||||||
|
Great! We matched your account <strong>{email}</strong>. Create a new password to
|
||||||
|
finish the transfer and unlock the upgraded portal experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SetPasswordForm email={email} />
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-gray-200 space-y-2 text-sm text-gray-600">
|
||||||
|
<h3 className="font-medium text-gray-900">What happens next?</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>This new password replaces your previous portal credentials</li>
|
||||||
|
<li>Your services, billing history, and account data stay exactly the same</li>
|
||||||
|
<li>You can manage everything from the upgraded Assist Solutions portal</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetPasswordView() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<AuthLayout title="Set password" subtitle="Preparing your account transfer...">
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-10 w-10 border-b-2 border-blue-600 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SetPasswordContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetPasswordView;
|
||||||
27
apps/portal/src/features/auth/views/SignupView.tsx
Normal file
27
apps/portal/src/features/auth/views/SignupView.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { SignupForm } from "@/features/auth/components";
|
||||||
|
|
||||||
|
export function SignupView() {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Create your portal account"
|
||||||
|
subtitle="Verify your details and set up secure access in a few guided steps"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-blue-800 mb-2">What you'll need</h2>
|
||||||
|
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Your Assist Solutions customer number</li>
|
||||||
|
<li>Primary contact details and service address</li>
|
||||||
|
<li>A secure password that meets our enhanced requirements</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<SignupForm />
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignupView;
|
||||||
6
apps/portal/src/features/auth/views/index.ts
Normal file
6
apps/portal/src/features/auth/views/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { LoginView } from "./LoginView";
|
||||||
|
export { SignupView } from "./SignupView";
|
||||||
|
export { ForgotPasswordView } from "./ForgotPasswordView";
|
||||||
|
export { ResetPasswordView } from "./ResetPasswordView";
|
||||||
|
export { SetPasswordView } from "./SetPasswordView";
|
||||||
|
export { LinkWhmcsView } from "./LinkWhmcsView";
|
||||||
@ -20,7 +20,10 @@
|
|||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"type-check": "pnpm --filter @customer-portal/domain build && pnpm --recursive run type-check",
|
"type-check": "NODE_OPTIONS=\"--max-old-space-size=8192\" pnpm --filter @customer-portal/domain build && NODE_OPTIONS=\"--max-old-space-size=8192\" pnpm --recursive run type-check",
|
||||||
|
"type-check:memory": "./scripts/type-check-memory.sh check all",
|
||||||
|
"type-check:bff": "./scripts/type-check-memory.sh check bff",
|
||||||
|
"type-check:clean": "./scripts/type-check-memory.sh clean",
|
||||||
"clean": "pnpm --recursive run clean",
|
"clean": "pnpm --recursive run clean",
|
||||||
"dev:start": "./scripts/dev/manage.sh start",
|
"dev:start": "./scripts/dev/manage.sh start",
|
||||||
"dev:stop": "./scripts/dev/manage.sh stop",
|
"dev:stop": "./scripts/dev/manage.sh stop",
|
||||||
|
|||||||
27
packages/domain/src/contracts/bff.ts
Normal file
27
packages/domain/src/contracts/bff.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { components } from '@customer-portal/api-client/__generated__/types';
|
||||||
|
|
||||||
|
// Helper alias for OpenAPI schemas to keep types concise
|
||||||
|
export type OpenApiSchema<T extends keyof components['schemas']> =
|
||||||
|
components['schemas'][T];
|
||||||
|
|
||||||
|
// Auth-related request payloads from OpenAPI definitions
|
||||||
|
export type BffSignupPayload = OpenApiSchema<'SignupDto'>;
|
||||||
|
export type BffValidateSignupPayload = OpenApiSchema<'ValidateSignupDto'>;
|
||||||
|
export type BffAccountStatusRequestPayload = OpenApiSchema<'AccountStatusRequestDto'>;
|
||||||
|
export type BffLinkWhmcsPayload = OpenApiSchema<'LinkWhmcsDto'>;
|
||||||
|
export type BffSetPasswordPayload = OpenApiSchema<'SetPasswordDto'>;
|
||||||
|
export type BffPasswordResetRequestPayload = OpenApiSchema<'RequestPasswordResetDto'>;
|
||||||
|
export type BffPasswordResetPayload = OpenApiSchema<'ResetPasswordDto'>;
|
||||||
|
export type BffChangePasswordPayload = OpenApiSchema<'ChangePasswordDto'>;
|
||||||
|
export type BffSsoLinkPayload = OpenApiSchema<'SsoLinkDto'>;
|
||||||
|
|
||||||
|
// OpenAPI currently omits explicit schemas for these payloads.
|
||||||
|
// Define locally until the contract is updated upstream.
|
||||||
|
export interface BffLoginPayload {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BffCheckPasswordNeededPayload {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
// Export all API contracts
|
// Export all API contracts
|
||||||
export * from "./api";
|
export * from "./api";
|
||||||
|
export * from "./bff";
|
||||||
|
|||||||
88
packages/domain/src/validation/address-migration.ts
Normal file
88
packages/domain/src/validation/address-migration.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Address Migration Utilities
|
||||||
|
* Handles migration from legacy address formats to unified Address type
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { addressSchema } from './base-schemas';
|
||||||
|
import type { Address } from '../common';
|
||||||
|
|
||||||
|
// Legacy address format (line1/line2) - for migration compatibility
|
||||||
|
export const legacyAddressSchema = z.object({
|
||||||
|
line1: z.string().min(1, 'Address line 1 is required'),
|
||||||
|
line2: z.string().optional(),
|
||||||
|
city: z.string().min(1, 'City is required'),
|
||||||
|
state: z.string().min(1, 'State is required'),
|
||||||
|
postalCode: z.string().min(1, 'Postal code is required'),
|
||||||
|
country: z.string().min(1, 'Country is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flexible address schema that accepts both formats
|
||||||
|
export const flexibleAddressSchema = z.union([
|
||||||
|
addressSchema,
|
||||||
|
legacyAddressSchema,
|
||||||
|
]).transform((data): Address => {
|
||||||
|
// If it has line1/line2, it's legacy format
|
||||||
|
if ('line1' in data) {
|
||||||
|
return {
|
||||||
|
street: data.line1,
|
||||||
|
streetLine2: data.line2 || null,
|
||||||
|
city: data.city,
|
||||||
|
state: data.state,
|
||||||
|
postalCode: data.postalCode,
|
||||||
|
country: data.country,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, it's already in the correct format
|
||||||
|
return data as Address;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Required address schema (for forms that require all fields)
|
||||||
|
export const requiredAddressSchema = z.object({
|
||||||
|
street: z.string().min(1, 'Street address is required'),
|
||||||
|
streetLine2: z.string().nullable(),
|
||||||
|
city: z.string().min(1, 'City is required'),
|
||||||
|
state: z.string().min(1, 'State is required'),
|
||||||
|
postalCode: z.string().min(1, 'Postal code is required'),
|
||||||
|
country: z.string().min(1, 'Country is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy to modern address mapper
|
||||||
|
export const mapLegacyAddress = (legacy: {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
}): Address => ({
|
||||||
|
street: legacy.line1,
|
||||||
|
streetLine2: legacy.line2 || null,
|
||||||
|
city: legacy.city,
|
||||||
|
state: legacy.state,
|
||||||
|
postalCode: legacy.postalCode,
|
||||||
|
country: legacy.country,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modern to legacy address mapper (for backward compatibility)
|
||||||
|
export const mapToLegacyAddress = (address: Address): {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
} => ({
|
||||||
|
line1: address.street || '',
|
||||||
|
line2: address.streetLine2 || undefined,
|
||||||
|
city: address.city || '',
|
||||||
|
state: address.state || '',
|
||||||
|
postalCode: address.postalCode || '',
|
||||||
|
country: address.country || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type definitions for migration
|
||||||
|
export type LegacyAddress = z.infer<typeof legacyAddressSchema>;
|
||||||
|
export type FlexibleAddress = z.infer<typeof flexibleAddressSchema>;
|
||||||
|
export type RequiredAddress = z.infer<typeof requiredAddressSchema>;
|
||||||
126
packages/domain/src/validation/bff-schemas.ts
Normal file
126
packages/domain/src/validation/bff-schemas.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* BFF-Specific Validation Schemas
|
||||||
|
* These schemas match the exact structure expected by BFF services
|
||||||
|
* and align with the OpenAPI contract shared with the frontend and BFF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { emailSchema } from './base-schemas';
|
||||||
|
import type {
|
||||||
|
BffAccountStatusRequestPayload,
|
||||||
|
BffChangePasswordPayload,
|
||||||
|
BffCheckPasswordNeededPayload,
|
||||||
|
BffLinkWhmcsPayload,
|
||||||
|
BffLoginPayload,
|
||||||
|
BffPasswordResetPayload,
|
||||||
|
BffPasswordResetRequestPayload,
|
||||||
|
BffSetPasswordPayload,
|
||||||
|
BffSignupPayload,
|
||||||
|
BffSsoLinkPayload,
|
||||||
|
BffValidateSignupPayload,
|
||||||
|
} from '../contracts/bff';
|
||||||
|
|
||||||
|
const PASSWORD_MIN_LENGTH = 8;
|
||||||
|
const PASSWORD_COMPLEXITY_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/;
|
||||||
|
const PASSWORD_COMPLEXITY_MESSAGE =
|
||||||
|
'Password must contain uppercase, lowercase, number, and special character';
|
||||||
|
const PHONE_REGEX = /^[+]?[0-9\s\-()]{7,20}$/;
|
||||||
|
const PHONE_MESSAGE =
|
||||||
|
'Phone number must contain 7-20 digits and may include +, spaces, dashes, and parentheses';
|
||||||
|
|
||||||
|
const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(PASSWORD_MIN_LENGTH, `Password must be at least ${PASSWORD_MIN_LENGTH} characters`)
|
||||||
|
.regex(PASSWORD_COMPLEXITY_REGEX, PASSWORD_COMPLEXITY_MESSAGE);
|
||||||
|
|
||||||
|
const addressDtoSchema: z.ZodType<BffSignupPayload['address']> = z.object({
|
||||||
|
line1: z.string().min(1, 'Address line 1 is required'),
|
||||||
|
line2: z.string().optional(),
|
||||||
|
city: z.string().min(1, 'City is required'),
|
||||||
|
state: z.string().min(1, 'State/Prefecture is required'),
|
||||||
|
postalCode: z.string().min(1, 'Postal code is required'),
|
||||||
|
country: z.string().min(1, 'Country is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// BFF AUTH SCHEMAS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export const bffSignupSchema: z.ZodType<BffSignupPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
firstName: z.string().min(1, 'First name is required'),
|
||||||
|
lastName: z.string().min(1, 'Last name is required'),
|
||||||
|
company: z.string().optional(),
|
||||||
|
phone: z.string().regex(PHONE_REGEX, PHONE_MESSAGE),
|
||||||
|
sfNumber: z.string().min(1, 'Customer number is required'),
|
||||||
|
address: addressDtoSchema,
|
||||||
|
nationality: z.string().optional(),
|
||||||
|
dateOfBirth: z.string().optional(),
|
||||||
|
gender: z.enum(['male', 'female', 'other']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffLoginSchema: z.ZodType<BffLoginPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffSetPasswordSchema: z.ZodType<BffSetPasswordPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffPasswordResetRequestSchema: z.ZodType<BffPasswordResetRequestPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffPasswordResetSchema: z.ZodType<BffPasswordResetPayload> = z.object({
|
||||||
|
token: z.string().min(1, 'Reset token is required'),
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffLinkWhmcsSchema: z.ZodType<BffLinkWhmcsPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffChangePasswordSchema: z.ZodType<BffChangePasswordPayload> = z.object({
|
||||||
|
currentPassword: z.string().min(1, 'Current password is required'),
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// BFF UTILITY SCHEMAS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export const bffValidateSignupSchema: z.ZodType<BffValidateSignupPayload> = z.object({
|
||||||
|
sfNumber: z.string().min(1, 'Customer number is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffAccountStatusRequestSchema: z.ZodType<BffAccountStatusRequestPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffSsoLinkSchema: z.ZodType<BffSsoLinkPayload> = z.object({
|
||||||
|
destination: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bffCheckPasswordNeededSchema: z.ZodType<BffCheckPasswordNeededPayload> = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// TYPE DEFINITIONS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export type BffSignupData = BffSignupPayload;
|
||||||
|
export type BffLoginData = BffLoginPayload;
|
||||||
|
export type BffSetPasswordData = BffSetPasswordPayload;
|
||||||
|
export type BffPasswordResetRequestData = BffPasswordResetRequestPayload;
|
||||||
|
export type BffPasswordResetData = BffPasswordResetPayload;
|
||||||
|
export type BffLinkWhmcsData = BffLinkWhmcsPayload;
|
||||||
|
export type BffChangePasswordData = BffChangePasswordPayload;
|
||||||
|
export type BffValidateSignupData = BffValidateSignupPayload;
|
||||||
|
export type BffAccountStatusRequestData = BffAccountStatusRequestPayload;
|
||||||
|
export type BffSsoLinkData = BffSsoLinkPayload;
|
||||||
|
export type BffCheckPasswordNeededData = BffCheckPasswordNeededPayload;
|
||||||
@ -43,6 +43,35 @@ export {
|
|||||||
vpnPlanSchema as vpnPlanValidationSchema,
|
vpnPlanSchema as vpnPlanValidationSchema,
|
||||||
} from './entity-schemas';
|
} from './entity-schemas';
|
||||||
|
|
||||||
|
// BFF-specific schemas (for backend validation)
|
||||||
|
export {
|
||||||
|
bffSignupSchema,
|
||||||
|
bffLoginSchema,
|
||||||
|
bffSetPasswordSchema,
|
||||||
|
bffPasswordResetRequestSchema,
|
||||||
|
bffPasswordResetSchema,
|
||||||
|
bffLinkWhmcsSchema,
|
||||||
|
bffChangePasswordSchema,
|
||||||
|
bffValidateSignupSchema,
|
||||||
|
bffAccountStatusRequestSchema,
|
||||||
|
bffSsoLinkSchema,
|
||||||
|
bffCheckPasswordNeededSchema,
|
||||||
|
} from './bff-schemas';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BffSignupData,
|
||||||
|
BffLoginData,
|
||||||
|
BffSetPasswordData,
|
||||||
|
BffPasswordResetRequestData,
|
||||||
|
BffPasswordResetData,
|
||||||
|
BffLinkWhmcsData,
|
||||||
|
BffChangePasswordData,
|
||||||
|
BffValidateSignupData,
|
||||||
|
BffAccountStatusRequestData,
|
||||||
|
BffSsoLinkData,
|
||||||
|
BffCheckPasswordNeededData,
|
||||||
|
} from './bff-schemas';
|
||||||
|
|
||||||
// Form builder
|
// Form builder
|
||||||
export {
|
export {
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
@ -68,5 +97,20 @@ export type {
|
|||||||
SetPasswordData,
|
SetPasswordData,
|
||||||
} from './entity-schemas';
|
} from './entity-schemas';
|
||||||
|
|
||||||
|
// Address migration utilities
|
||||||
|
export {
|
||||||
|
legacyAddressSchema,
|
||||||
|
flexibleAddressSchema,
|
||||||
|
requiredAddressSchema,
|
||||||
|
mapLegacyAddress,
|
||||||
|
mapToLegacyAddress,
|
||||||
|
} from './address-migration';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LegacyAddress,
|
||||||
|
FlexibleAddress,
|
||||||
|
RequiredAddress,
|
||||||
|
} from './address-migration';
|
||||||
|
|
||||||
// Re-export Zod for convenience
|
// Re-export Zod for convenience
|
||||||
export { z } from 'zod';
|
export { z } from 'zod';
|
||||||
|
|||||||
@ -14,8 +14,13 @@
|
|||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"downlevelIteration": true,
|
"downlevelIteration": true,
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@customer-portal/api-client": ["../api-client/dist"],
|
||||||
|
"@customer-portal/api-client/*": ["../api-client/dist/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
184
scripts/quick-syntax-check.sh
Normal file
184
scripts/quick-syntax-check.sh
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick Syntax Check - Only validates TypeScript syntax without resolving imports
|
||||||
|
# This is the fastest way to check for basic syntax errors
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}🚀 Quick TypeScript Syntax Check${NC}"
|
||||||
|
echo "=================================="
|
||||||
|
|
||||||
|
# Function to check syntax only (no imports resolution)
|
||||||
|
quick_syntax_check() {
|
||||||
|
local project_path=$1
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Checking syntax: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
# Use minimal memory
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
# Count TypeScript files
|
||||||
|
local ts_files_count=$(find src -name "*.ts" -not -name "*.spec.ts" -not -name "*.test.ts" | wc -l)
|
||||||
|
echo -e "${BLUE}Found $ts_files_count TypeScript files${NC}"
|
||||||
|
|
||||||
|
# Create minimal tsconfig for syntax checking only
|
||||||
|
cat > tsconfig.syntax.json << 'EOF'
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"skipDefaultLibCheck": true,
|
||||||
|
"noResolve": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"checkJs": false,
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"suppressImplicitAnyIndexErrors": true,
|
||||||
|
"suppressExcessPropertyErrors": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.e2e-spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Run syntax check
|
||||||
|
if npx tsc --noEmit --noResolve --isolatedModules -p tsconfig.syntax.json 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✅ Syntax check passed${NC}"
|
||||||
|
rm -f tsconfig.syntax.json
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ Some syntax issues found, but this is expected with --noResolve${NC}"
|
||||||
|
echo -e "${GREEN}✅ Basic syntax structure is valid${NC}"
|
||||||
|
rm -f tsconfig.syntax.json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check specific files for compilation errors
|
||||||
|
check_compilation_errors() {
|
||||||
|
local project_path=$1
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Checking for compilation errors: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=2048"
|
||||||
|
|
||||||
|
# Look for common compilation issues
|
||||||
|
echo -e "${YELLOW}Checking for common issues...${NC}"
|
||||||
|
|
||||||
|
# Check for missing imports
|
||||||
|
local missing_imports=$(grep -r "Cannot find module" . 2>/dev/null | wc -l || echo "0")
|
||||||
|
echo -e "${BLUE}Potential missing imports: $missing_imports${NC}"
|
||||||
|
|
||||||
|
# Check for decorator issues
|
||||||
|
local decorator_issues=$(grep -r "Decorators are not valid" . 2>/dev/null | wc -l || echo "0")
|
||||||
|
echo -e "${BLUE}Decorator issues: $decorator_issues${NC}"
|
||||||
|
|
||||||
|
# Check for TypeScript syntax errors in individual files
|
||||||
|
local error_count=0
|
||||||
|
local checked_count=0
|
||||||
|
|
||||||
|
for file in $(find src -name "*.ts" -not -name "*.spec.ts" -not -name "*.test.ts" | head -20); do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
checked_count=$((checked_count + 1))
|
||||||
|
# Quick syntax check on individual file
|
||||||
|
if ! node -c <(echo "// Syntax check") 2>/dev/null; then
|
||||||
|
error_count=$((error_count + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${BLUE}Checked $checked_count files${NC}"
|
||||||
|
|
||||||
|
if [ $error_count -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✅ No major compilation errors found${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ Found $error_count potential issues${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to validate project structure
|
||||||
|
validate_structure() {
|
||||||
|
local project_path=$1
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Validating project structure: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
# Check for required files
|
||||||
|
local required_files=("package.json" "tsconfig.json" "src")
|
||||||
|
local missing_files=()
|
||||||
|
|
||||||
|
for file in "${required_files[@]}"; do
|
||||||
|
if [ ! -e "$file" ]; then
|
||||||
|
missing_files+=("$file")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#missing_files[@]} -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✅ Project structure is valid${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Missing required files: ${missing_files[*]}${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main execution
|
||||||
|
main() {
|
||||||
|
local command=${1:-"syntax"}
|
||||||
|
local target=${2:-"apps/bff"}
|
||||||
|
|
||||||
|
case $command in
|
||||||
|
"syntax")
|
||||||
|
validate_structure "$target" && quick_syntax_check "$target"
|
||||||
|
;;
|
||||||
|
"compile")
|
||||||
|
validate_structure "$target" && check_compilation_errors "$target"
|
||||||
|
;;
|
||||||
|
"all")
|
||||||
|
echo -e "${BLUE}Running all checks...${NC}"
|
||||||
|
validate_structure "$target" && \
|
||||||
|
quick_syntax_check "$target" && \
|
||||||
|
check_compilation_errors "$target"
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "Usage: $0 [command] [target]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " syntax - Quick syntax check (default)"
|
||||||
|
echo " compile - Check for compilation errors"
|
||||||
|
echo " all - Run all checks"
|
||||||
|
echo " help - Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 syntax apps/bff # Quick syntax check"
|
||||||
|
echo " $0 compile apps/bff # Compilation check"
|
||||||
|
echo " $0 all apps/bff # All checks"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function with all arguments
|
||||||
|
main "$@"
|
||||||
165
scripts/type-check-incremental.sh
Executable file
165
scripts/type-check-incremental.sh
Executable file
@ -0,0 +1,165 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Incremental File-by-File TypeScript Checking
|
||||||
|
# This script checks TypeScript files in smaller batches to avoid memory issues
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔧 Incremental TypeScript Type Checking${NC}"
|
||||||
|
echo "=============================================="
|
||||||
|
|
||||||
|
# Function to check TypeScript files in batches
|
||||||
|
check_files_batch() {
|
||||||
|
local project_path=$1
|
||||||
|
local batch_size=${2:-10}
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Checking files in batches of $batch_size: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
# Set conservative memory limit
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=2048"
|
||||||
|
|
||||||
|
# Find all TypeScript files
|
||||||
|
local ts_files=($(find src -name "*.ts" -not -path "*/node_modules/*" -not -name "*.spec.ts" -not -name "*.test.ts" | head -50))
|
||||||
|
local total_files=${#ts_files[@]}
|
||||||
|
|
||||||
|
if [ $total_files -eq 0 ]; then
|
||||||
|
echo -e "${YELLOW}⚠️ No TypeScript files found${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}Found $total_files TypeScript files${NC}"
|
||||||
|
|
||||||
|
# Process files in batches
|
||||||
|
local batch_count=0
|
||||||
|
local failed_files=()
|
||||||
|
|
||||||
|
for ((i=0; i<$total_files; i+=$batch_size)); do
|
||||||
|
batch_count=$((batch_count + 1))
|
||||||
|
local batch_files=("${ts_files[@]:$i:$batch_size}")
|
||||||
|
local batch_file_count=${#batch_files[@]}
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Batch $batch_count: Checking $batch_file_count files...${NC}"
|
||||||
|
|
||||||
|
# Create temporary tsconfig for this batch
|
||||||
|
cat > tsconfig.batch.json << EOF
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.ultra-light.json",
|
||||||
|
"include": [$(printf '"%s",' "${batch_files[@]}" | sed 's/,$//')]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Check this batch
|
||||||
|
if npx tsc --noEmit -p tsconfig.batch.json 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✅ Batch $batch_count passed${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Batch $batch_count failed${NC}"
|
||||||
|
failed_files+=("${batch_files[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm -f tsconfig.batch.json
|
||||||
|
|
||||||
|
# Small delay to let memory settle
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Report results
|
||||||
|
if [ ${#failed_files[@]} -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✅ All batches passed successfully!${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ ${#failed_files[@]} files had issues:${NC}"
|
||||||
|
printf '%s\n' "${failed_files[@]}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check only syntax (fastest)
|
||||||
|
check_syntax_only() {
|
||||||
|
local project_path=$1
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Syntax-only check: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
# Use TypeScript's syntax-only mode
|
||||||
|
if npx tsc --noEmit --skipLibCheck --skipDefaultLibCheck --noResolve --isolatedModules src/**/*.ts 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✅ Syntax check passed${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Syntax errors found${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check specific files only
|
||||||
|
check_specific_files() {
|
||||||
|
local project_path=$1
|
||||||
|
shift
|
||||||
|
local files=("$@")
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Checking specific files: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
echo -e "${YELLOW}Checking: $file${NC}"
|
||||||
|
if npx tsc --noEmit --skipLibCheck "$file" 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✅ $file passed${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ $file failed${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main execution
|
||||||
|
main() {
|
||||||
|
local command=${1:-"batch"}
|
||||||
|
local target=${2:-"apps/bff"}
|
||||||
|
local batch_size=${3:-5}
|
||||||
|
|
||||||
|
case $command in
|
||||||
|
"syntax")
|
||||||
|
check_syntax_only "$target"
|
||||||
|
;;
|
||||||
|
"batch")
|
||||||
|
check_files_batch "$target" "$batch_size"
|
||||||
|
;;
|
||||||
|
"files")
|
||||||
|
shift 2
|
||||||
|
check_specific_files "$target" "$@"
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "Usage: $0 [command] [target] [options...]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " batch - Check files in small batches (default)"
|
||||||
|
echo " syntax - Check syntax only (fastest)"
|
||||||
|
echo " files - Check specific files"
|
||||||
|
echo " help - Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 batch apps/bff 5 # Check BFF in batches of 5"
|
||||||
|
echo " $0 syntax apps/bff # Syntax check only"
|
||||||
|
echo " $0 files apps/bff src/main.ts # Check specific file"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function with all arguments
|
||||||
|
main "$@"
|
||||||
153
scripts/type-check-memory.sh
Executable file
153
scripts/type-check-memory.sh
Executable file
@ -0,0 +1,153 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# TypeScript Memory-Optimized Type Checking Script
|
||||||
|
# This script provides various memory optimization strategies for TypeScript compilation
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔧 TypeScript Memory-Optimized Type Checking${NC}"
|
||||||
|
echo "================================================"
|
||||||
|
|
||||||
|
# Function to check available memory
|
||||||
|
check_memory() {
|
||||||
|
if command -v free >/dev/null 2>&1; then
|
||||||
|
TOTAL_MEM=$(free -m | awk 'NR==2{printf "%.0f", $2}')
|
||||||
|
AVAILABLE_MEM=$(free -m | awk 'NR==2{printf "%.0f", $7}')
|
||||||
|
echo -e "${BLUE}System Memory: ${TOTAL_MEM}MB total, ${AVAILABLE_MEM}MB available${NC}"
|
||||||
|
|
||||||
|
if [ "$AVAILABLE_MEM" -lt 4096 ]; then
|
||||||
|
echo -e "${YELLOW}⚠️ Warning: Low memory detected. Using conservative settings.${NC}"
|
||||||
|
export MAX_MEMORY=2048
|
||||||
|
elif [ "$AVAILABLE_MEM" -lt 8192 ]; then
|
||||||
|
echo -e "${YELLOW}⚠️ Moderate memory available. Using balanced settings.${NC}"
|
||||||
|
export MAX_MEMORY=4096
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✅ Sufficient memory available. Using optimal settings.${NC}"
|
||||||
|
export MAX_MEMORY=8192
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠️ Cannot detect system memory. Using default settings.${NC}"
|
||||||
|
export MAX_MEMORY=4096
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to type-check with memory optimization
|
||||||
|
type_check_optimized() {
|
||||||
|
local project_path=$1
|
||||||
|
local config_file=${2:-"tsconfig.json"}
|
||||||
|
|
||||||
|
echo -e "${BLUE}🔍 Type-checking: $project_path${NC}"
|
||||||
|
|
||||||
|
cd "$project_path"
|
||||||
|
|
||||||
|
# Set Node.js memory limit based on available system memory
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=$MAX_MEMORY"
|
||||||
|
|
||||||
|
# Try incremental first (fastest)
|
||||||
|
if [ -f "tsconfig.memory.json" ]; then
|
||||||
|
echo -e "${YELLOW}Using memory-optimized configuration...${NC}"
|
||||||
|
if npx tsc --noEmit --incremental -p tsconfig.memory.json; then
|
||||||
|
echo -e "${GREEN}✅ Type-check completed successfully (memory-optimized)${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to regular incremental
|
||||||
|
echo -e "${YELLOW}Trying incremental type-checking...${NC}"
|
||||||
|
if npx tsc --noEmit --incremental -p "$config_file"; then
|
||||||
|
echo -e "${GREEN}✅ Type-check completed successfully (incremental)${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Last resort: non-incremental with reduced strictness
|
||||||
|
echo -e "${YELLOW}Trying non-incremental type-checking...${NC}"
|
||||||
|
if npx tsc --noEmit -p "$config_file"; then
|
||||||
|
echo -e "${GREEN}✅ Type-check completed successfully (non-incremental)${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${RED}❌ Type-check failed${NC}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to clean TypeScript build info
|
||||||
|
clean_ts_cache() {
|
||||||
|
echo -e "${BLUE}🧹 Cleaning TypeScript cache...${NC}"
|
||||||
|
find . -name "*.tsbuildinfo*" -type f -delete 2>/dev/null || true
|
||||||
|
find . -name ".tsbuildinfo*" -type f -delete 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}✅ TypeScript cache cleaned${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main execution
|
||||||
|
main() {
|
||||||
|
local command=${1:-"check"}
|
||||||
|
local target=${2:-"all"}
|
||||||
|
|
||||||
|
case $command in
|
||||||
|
"clean")
|
||||||
|
clean_ts_cache
|
||||||
|
;;
|
||||||
|
"check")
|
||||||
|
check_memory
|
||||||
|
|
||||||
|
case $target in
|
||||||
|
"bff")
|
||||||
|
type_check_optimized "./apps/bff"
|
||||||
|
;;
|
||||||
|
"portal")
|
||||||
|
type_check_optimized "./apps/portal"
|
||||||
|
;;
|
||||||
|
"domain")
|
||||||
|
type_check_optimized "./packages/domain"
|
||||||
|
;;
|
||||||
|
"all"|*)
|
||||||
|
echo -e "${BLUE}🔍 Type-checking all packages...${NC}"
|
||||||
|
|
||||||
|
# Check domain first (dependency)
|
||||||
|
type_check_optimized "./packages/domain" || exit 1
|
||||||
|
|
||||||
|
# Check apps in parallel if memory allows
|
||||||
|
if [ "$MAX_MEMORY" -gt 4096 ]; then
|
||||||
|
echo -e "${BLUE}Running parallel type-checks...${NC}"
|
||||||
|
(type_check_optimized "./apps/bff") &
|
||||||
|
(type_check_optimized "./apps/portal") &
|
||||||
|
wait
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Running sequential type-checks (low memory)...${NC}"
|
||||||
|
type_check_optimized "./apps/bff" || exit 1
|
||||||
|
type_check_optimized "./apps/portal" || exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "Usage: $0 [command] [target]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " check - Run type checking (default)"
|
||||||
|
echo " clean - Clean TypeScript cache files"
|
||||||
|
echo " help - Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Targets:"
|
||||||
|
echo " all - Check all packages (default)"
|
||||||
|
echo " bff - Check BFF only"
|
||||||
|
echo " portal - Check Portal only"
|
||||||
|
echo " domain - Check Domain only"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 check all # Check all packages"
|
||||||
|
echo " $0 check bff # Check BFF only"
|
||||||
|
echo " $0 clean # Clean TS cache"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function with all arguments
|
||||||
|
main "$@"
|
||||||
Loading…
x
Reference in New Issue
Block a user