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,
|
||||
"license": "UNLICENSED",
|
||||
"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\"",
|
||||
"start": "nest start",
|
||||
"dev": "NODE_OPTIONS=\"--no-deprecation\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
|
||||
"start:debug": "NODE_OPTIONS=\"--no-deprecation\" nest start --debug --watch",
|
||||
"dev": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --watch --preserveWatchOutput -c tsconfig.build.json",
|
||||
"start:debug": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest",
|
||||
"test:watch": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --watch",
|
||||
"test:cov": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test:e2e": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --config ./test/jest-e2e.json",
|
||||
"type-check": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit",
|
||||
"type-check:incremental": "NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit --incremental",
|
||||
"clean": "rm -rf dist",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
"db:studio": "prisma studio",
|
||||
"db:reset": "prisma migrate reset",
|
||||
"db:seed": "ts-node prisma/seed.ts",
|
||||
"openapi:gen": "TS_NODE_TRANSPILE_ONLY=1 ts-node -r tsconfig-paths/register ./scripts/generate-openapi.ts"
|
||||
"db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" ts-node prisma/seed.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": {
|
||||
"@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 { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthZodController } from "./auth-zod.controller";
|
||||
import { AuthAdminController } from "./auth-admin.controller";
|
||||
import { UsersModule } from "@/users/users.module";
|
||||
import { 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 { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||
import { EmailModule } from "@bff/infra/email/email.module";
|
||||
import { ValidationModule } from "@bff/core/validation";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -29,8 +31,9 @@ import { EmailModule } from "@bff/infra/email/email.module";
|
||||
MappingsModule,
|
||||
IntegrationsModule,
|
||||
EmailModule,
|
||||
ValidationModule,
|
||||
],
|
||||
controllers: [AuthController, AuthAdminController],
|
||||
controllers: [AuthZodController, AuthAdminController],
|
||||
providers: [
|
||||
AuthService,
|
||||
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,
|
||||
"strictPropertyInitialization": false,
|
||||
"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 { 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;
|
||||
}
|
||||
import ProfileContainer from "@/features/account/views/ProfileContainer";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuthStore();
|
||||
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>
|
||||
);
|
||||
return <ProfileContainer />;
|
||||
}
|
||||
|
||||
@ -1,374 +1,5 @@
|
||||
"use client";
|
||||
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";
|
||||
import InvoiceDetailContainer from "@/features/billing/views/InvoiceDetail";
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const params = useParams();
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return <InvoiceDetailContainer />;
|
||||
}
|
||||
|
||||
@ -1,306 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import InvoicesListContainer from "@/features/billing/views/InvoicesList";
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <InvoicesListContainer />;
|
||||
}
|
||||
|
||||
@ -1,175 +1,5 @@
|
||||
"use client";
|
||||
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";
|
||||
import PaymentMethodsContainer from "@/features/billing/views/PaymentMethods";
|
||||
|
||||
export default function PaymentMethodsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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>
|
||||
);
|
||||
return <PaymentMethodsContainer />;
|
||||
}
|
||||
|
||||
@ -1,762 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import InternetConfigureContainer from "@/features/catalog/views/InternetConfigure";
|
||||
|
||||
export default function InternetConfigurePage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner message="Loading internet configuration..." />}>
|
||||
<InternetConfigureContent />
|
||||
</Suspense>
|
||||
);
|
||||
return <InternetConfigureContainer />;
|
||||
}
|
||||
|
||||
@ -1,325 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import { InternetPlansContainer } from "@/features/catalog/views/InternetPlans";
|
||||
|
||||
export default function InternetPlansPage() {
|
||||
const [plans, setPlans] = useState<InternetPlan[]>([]);
|
||||
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>
|
||||
);
|
||||
return <InternetPlansContainer />;
|
||||
}
|
||||
|
||||
@ -1,224 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import CatalogHomeView from "@/features/catalog/views/CatalogHome";
|
||||
|
||||
export default function CatalogPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <CatalogHomeView />;
|
||||
}
|
||||
|
||||
@ -1,829 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import SimConfigureContainer from "@/features/catalog/views/SimConfigure";
|
||||
|
||||
export default function SimConfigurePage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner message="Loading SIM configuration..." />}>
|
||||
<SimConfigureContent />
|
||||
</Suspense>
|
||||
);
|
||||
return <SimConfigureContainer />;
|
||||
}
|
||||
|
||||
@ -1,506 +1,5 @@
|
||||
"use client";
|
||||
import SimPlansView from "@/features/catalog/views/SimPlans";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
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>
|
||||
);
|
||||
export default function SimCatalogPage() {
|
||||
return <SimPlansView />;
|
||||
}
|
||||
|
||||
@ -1,207 +1,5 @@
|
||||
"use client";
|
||||
import VpnPlansView from "@/features/catalog/views/VpnPlans";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
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>
|
||||
);
|
||||
export default function VpnCatalogPage() {
|
||||
return <VpnPlansView />;
|
||||
}
|
||||
|
||||
@ -1,552 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import CheckoutContainer from "@/features/checkout/views/CheckoutContainer";
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-12">Loading checkout...</div>}>
|
||||
<CheckoutContent />
|
||||
</Suspense>
|
||||
);
|
||||
return <CheckoutContainer />;
|
||||
}
|
||||
|
||||
@ -1,517 +1,5 @@
|
||||
"use client";
|
||||
import OrderDetailContainer from "@/features/orders/views/OrderDetail";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
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>
|
||||
);
|
||||
export default function OrderDetailPage() {
|
||||
return <OrderDetailContainer />;
|
||||
}
|
||||
|
||||
@ -1,333 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import OrdersListContainer from "@/features/orders/views/OrdersList";
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <OrdersListContainer />;
|
||||
}
|
||||
|
||||
@ -1,495 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
import SubscriptionDetailContainer from "@/features/subscriptions/views/SubscriptionDetail";
|
||||
|
||||
export default function SubscriptionDetailPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return <SubscriptionDetailContainer />;
|
||||
}
|
||||
|
||||
@ -1,352 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import SimCancelContainer from "@/features/subscriptions/views/SimCancel";
|
||||
|
||||
export default function SimCancelPage() {
|
||||
const params = useParams();
|
||||
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>
|
||||
);
|
||||
return <SimCancelContainer />;
|
||||
}
|
||||
|
||||
@ -1,145 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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",
|
||||
};
|
||||
import SimChangePlanContainer from "@/features/subscriptions/views/SimChangePlan";
|
||||
|
||||
export default function SimChangePlanPage() {
|
||||
const params = useParams();
|
||||
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>
|
||||
);
|
||||
return <SimChangePlanContainer />;
|
||||
}
|
||||
|
||||
@ -1,168 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import SimTopUpContainer from "@/features/subscriptions/views/SimTopUp";
|
||||
|
||||
export default function SimTopUpPage() {
|
||||
const params = useParams();
|
||||
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>
|
||||
);
|
||||
return <SimTopUpContainer />;
|
||||
}
|
||||
|
||||
@ -1,344 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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
|
||||
import SubscriptionsListContainer from "@/features/subscriptions/views/SubscriptionsList";
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <SubscriptionsListContainer />;
|
||||
}
|
||||
|
||||
@ -1,77 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>;
|
||||
import ForgotPasswordView from "@/features/auth/views/ForgotPasswordView";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { requestPasswordReset, isLoading } = useAuthStore();
|
||||
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>
|
||||
);
|
||||
return <ForgotPasswordView />;
|
||||
}
|
||||
|
||||
@ -1,156 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>;
|
||||
import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView";
|
||||
|
||||
export default function LinkWhmcsPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <LinkWhmcsView />;
|
||||
}
|
||||
|
||||
@ -1,114 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>;
|
||||
import LoginView from "@/features/auth/views/LoginView";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <LoginView />;
|
||||
}
|
||||
|
||||
@ -1,118 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import ResetPasswordView from "@/features/auth/views/ResetPasswordView";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <ResetPasswordView />;
|
||||
}
|
||||
|
||||
@ -1,182 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import SetPasswordView from "@/features/auth/views/SetPasswordView";
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <SetPasswordView />;
|
||||
}
|
||||
|
||||
@ -1,769 +1,5 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
}
|
||||
import SignupView from "@/features/auth/views/SignupView";
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
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>
|
||||
);
|
||||
return <SignupView />;
|
||||
}
|
||||
|
||||
@ -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 { PasswordResetForm } from "./PasswordResetForm";
|
||||
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:check": "prettier -c .",
|
||||
"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",
|
||||
"dev:start": "./scripts/dev/manage.sh start",
|
||||
"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 * 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,
|
||||
} 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
|
||||
export {
|
||||
FormBuilder,
|
||||
@ -68,5 +97,20 @@ export type {
|
||||
SetPasswordData,
|
||||
} 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
|
||||
export { z } from 'zod';
|
||||
|
||||
@ -14,7 +14,12 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"downlevelIteration": true,
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@customer-portal/api-client": ["../api-client/dist"],
|
||||
"@customer-portal/api-client/*": ["../api-client/dist/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"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