diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..1d9b7831 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/apps/bff/package.json b/apps/bff/package.json index a2f8644c..29166963 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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:*", diff --git a/apps/bff/src/auth/auth-zod.controller.ts b/apps/bff/src/auth/auth-zod.controller.ts new file mode 100644 index 00000000..1b48b2af --- /dev/null +++ b/apps/bff/src/auth/auth-zod.controller.ts @@ -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); + } +} diff --git a/apps/bff/src/auth/auth.module.ts b/apps/bff/src/auth/auth.module.ts index a0e78138..36a163b0 100644 --- a/apps/bff/src/auth/auth.module.ts +++ b/apps/bff/src/auth/auth.module.ts @@ -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, diff --git a/apps/bff/src/core/validation/index.ts b/apps/bff/src/core/validation/index.ts new file mode 100644 index 00000000..7d5ead37 --- /dev/null +++ b/apps/bff/src/core/validation/index.ts @@ -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'; diff --git a/apps/bff/src/core/validation/validation.module.ts b/apps/bff/src/core/validation/validation.module.ts new file mode 100644 index 00000000..ead58abb --- /dev/null +++ b/apps/bff/src/core/validation/validation.module.ts @@ -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 {} diff --git a/apps/bff/src/core/validation/zod-validation.pipe.ts b/apps/bff/src/core/validation/zod-validation.pipe.ts new file mode 100644 index 00000000..e2b8417a --- /dev/null +++ b/apps/bff/src/core/validation/zod-validation.pipe.ts @@ -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 + }; +}; diff --git a/apps/bff/tsconfig.base.json b/apps/bff/tsconfig.base.json index 66f25c22..793210c9 100644 --- a/apps/bff/tsconfig.base.json +++ b/apps/bff/tsconfig.base.json @@ -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" + } } } diff --git a/apps/bff/tsconfig.memory.json b/apps/bff/tsconfig.memory.json new file mode 100644 index 00000000..dcdd641d --- /dev/null +++ b/apps/bff/tsconfig.memory.json @@ -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" + ] +} diff --git a/apps/bff/tsconfig.ultra-light.json b/apps/bff/tsconfig.ultra-light.json new file mode 100644 index 00000000..85f1320b --- /dev/null +++ b/apps/bff/tsconfig.ultra-light.json @@ -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" + ] +} diff --git a/apps/portal/src/app/(portal)/account/profile/page.tsx b/apps/portal/src/app/(portal)/account/profile/page.tsx index 8a04e39a..b97286f7 100644 --- a/apps/portal/src/app/(portal)/account/profile/page.tsx +++ b/apps/portal/src/app/(portal)/account/profile/page.tsx @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [pwdError, setPwdError] = useState(null); - const [pwdSuccess, setPwdSuccess] = useState(null); - - const [formData, setFormData] = useState({ - firstName: user?.firstName || "", - lastName: user?.lastName || "", - email: user?.email || "", - phone: user?.phone || "", - }); - - const [addressData, setAddressData] = useState
({ - 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("/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; - - // 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("/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 ( -
-
-
-
- Loading profile... -
-
-
- ); - } - - return ( -
-
- {/* Header */} -
-
-
- - {user?.firstName?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || "U"} - -
-
-

- {user?.firstName && user?.lastName - ? `${user.firstName} ${user.lastName}` - : user?.firstName - ? user.firstName - : "Profile"} -

-

{user?.email}

-

- Manage your personal information and address -

-
-
-
- - {/* Error Banner */} - {error && ( -
-
- -
-

Error

-

{error}

-
-
-
- )} - -
- {/* Personal Information */} -
-
-
-
- -

Personal Information

-
- {!isEditing && ( - - )} -
-
- -
-
- {/* First Name */} -
- - {isEditing ? ( - 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" - /> - ) : ( -

- {user?.firstName || ( - Not provided - )} -

- )} -
- - {/* Last Name */} -
- - {isEditing ? ( - 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" - /> - ) : ( -

- {user?.lastName || ( - Not provided - )} -

- )} -
- - {/* Email */} -
- -
-
-

{user?.email}

- - - - - Verified - -
-

- Email cannot be changed. Contact support if you need to update your email - address. -

-
-
- - {/* Phone */} -
- - {isEditing ? ( - 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" - /> - ) : ( -

- {user?.phone || Not provided} -

- )} -
-
- - {/* Edit Actions */} - {isEditing && ( -
- - -
- )} -
-
- - {/* Address Information */} -
-
-
-
- -

Address Information

-
- {!isEditingAddress && ( - - )} -
-
- -
- {isEditingAddress ? ( -
- {/* Street Address */} -
- - 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 - /> -
- - {/* Street Address Line 2 */} -
- - 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)" - /> -
- - {/* City, State, Postal Code */} -
-
- - 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 - /> -
- -
- - 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 - /> -
- -
- - 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 - /> -
-
- - {/* Country */} -
- - -
- - {/* Address Edit Actions */} -
- - -
-
- ) : ( -
- {billingInfo?.address && - (billingInfo.address.street || billingInfo.address.city) ? ( -
-
- -
- {billingInfo.address.street && ( -

- {billingInfo.address.street} -

- )} - {billingInfo.address.streetLine2 && ( -

{billingInfo.address.streetLine2}

- )} - {(billingInfo.address.city || - billingInfo.address.state || - billingInfo.address.postalCode) && ( -

- {[ - billingInfo.address.city, - billingInfo.address.state, - billingInfo.address.postalCode, - ] - .filter(Boolean) - .join(", ")} -

- )} - {billingInfo.address.country && ( -

- {(countries as Country[]).find( - (c: Country) => c.cca2 === billingInfo.address.country - )?.name.common || billingInfo.address.country} -

- )} -
-
-
- ) : ( -
- -

No address on file

- -
- )} -
- )} -
-
- - {/* Change Password */} -
-
-

Change Password

-
-
- {pwdSuccess && ( -
- {pwdSuccess} -
- )} - {pwdError && ( -
- {pwdError} -
- )} -
-
- - 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="••••••••" - /> -
-
- - 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" - /> -
-
- - 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" - /> -
-
-
- -
-

- Password must be at least 8 characters and include uppercase, lowercase, number, - and special character. -

-
-
-
-
-
- ); + return ; } diff --git a/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx b/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx index e790a0ff..96b28667 100644 --- a/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx +++ b/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx @@ -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 ( -
-
-
-

Loading invoice...

-
-
- ); - } - - if (error || !invoice) { - return ( -
-
-
-
- -
-
-

Error loading invoice

-
- {error instanceof Error ? error.message : "Invoice not found"} -
-
- - ← Back to invoices - -
-
-
-
-
- ); - } - - return ( - <> -
-
- {/* Back Button */} -
- - - Back to Invoices - -
- - {/* Invoice Card */} -
- {/* Invoice Header */} -
-
- {/* Left: Invoice Info */} -
-
-

Invoice #{invoice.number}

- {/* Harmonize with StatusPill while keeping existing badge for now */} - -
- -
-
- Issued: - - {formatDate(invoice.issuedAt)} - -
- {invoice.dueDate && ( -
- Due: - - {formatDate(invoice.dueDate)} - {invoice.status === "Overdue" && " • OVERDUE"} - -
- )} -
-
- - {/* Right: Actions */} -
- - - {(invoice.status === "Unpaid" || invoice.status === "Overdue") && ( - <> - - - - - )} -
-
- - {/* Paid Status Banner */} - {invoice.status === "Paid" && ( -
- -
- Invoice Paid - - • Paid on {formatDate(invoice.paidDate || invoice.issuedAt)} - -
-
- )} -
- - {/* Invoice Body */} -
- {/* Items */} - - {invoice.items && invoice.items.length > 0 ? ( -
- {invoice.items.map((item: import("@customer-portal/shared").InvoiceItem) => ( - - ))} -
- ) : ( -
No items found on this invoice.
- )} -
- - {/* Totals */} - -
-
-
- Subtotal - {fmt(invoice.subtotal, invoice.currency)} -
- {invoice.tax > 0 && ( -
- Tax - {fmt(invoice.tax, invoice.currency)} -
- )} -
-
- Total - - {fmt(invoice.total, invoice.currency)} - -
-
-
-
-
- - {/* Actions */} - {(invoice.status === "Unpaid" || invoice.status === "Overdue") && ( - -
- - -
-
- )} -
-
-
-
- - ); + return ; } diff --git a/apps/portal/src/app/(portal)/billing/invoices/page.tsx b/apps/portal/src/app/(portal)/billing/invoices/page.tsx index 1236ede0..3544a515 100644 --- a/apps/portal/src/app/(portal)/billing/invoices/page.tsx +++ b/apps/portal/src/app/(portal)/billing/invoices/page.tsx @@ -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 ; - case "Unpaid": - return ; - case "Overdue": - return ; - case "Cancelled": - return ; - default: - return ; - } - }; - - 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) => ( -
- {getStatusIcon(invoice.status)} -
-
{invoice.number}
-
-
- ), - }, - { - key: "status", - header: "Status", - render: (invoice: Invoice) => ( - - ), - }, - { - key: "amount", - header: "Amount", - render: (invoice: Invoice) => ( - - {formatCurrency(invoice.total, { - currency: invoice.currency, - currencySymbol: invoice.currencySymbol, - locale: getCurrencyLocale(invoice.currency), - })} - - ), - }, - { - key: "invoiceDate", - header: "Invoice Date", - render: (invoice: Invoice) => ( - - {invoice.issuedAt ? format(new Date(invoice.issuedAt), "MMM d, yyyy") : "N/A"} - - ), - }, - { - key: "dueDate", - header: "Due Date", - render: (invoice: Invoice) => ( - - {invoice.dueDate ? format(new Date(invoice.dueDate), "MMM d, yyyy") : "N/A"} - - ), - }, - { - key: "actions", - header: "", - className: "relative", - render: (invoice: Invoice) => ( -
- - View - - -
- ), - }, - ]; - - if (isLoading) { - return ( - } - title="Invoices" - description="Manage and view your billing invoices" - > -
-
- -

Loading invoices...

-
-
-
- ); - } - - if (error) { - return ( - } - title="Invoices" - description="Manage and view your billing invoices" - > - - - ); - } - - return ( - } - title="Invoices" - description="Manage and view your billing invoices" - > - {/* Invoice Table with integrated header filters */} - { - 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 ? ( -
-
- - -
-
-
-

- Showing{" "} - {(currentPage - 1) * itemsPerPage + 1} to{" "} - - {Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)} - {" "} - of {pagination?.totalItems || 0} results -

-
-
- -
-
-
- ) : undefined - } - > - , - 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}`)} - /> -
- - {/* Pagination */} - {/* Pagination moved to SubCard footer above */} -
- ); + return ; } diff --git a/apps/portal/src/app/(portal)/billing/payments/page.tsx b/apps/portal/src/app/(portal)/billing/payments/page.tsx index 666de344..b3ab158e 100644 --- a/apps/portal/src/app/(portal)/billing/payments/page.tsx +++ b/apps/portal/src/app/(portal)/billing/payments/page.tsx @@ -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(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 ( - } - title="Payment Methods" - description="Manage your saved payment methods and billing information" - > -
-
-
-
- -
-

- Unable to Access Payment Methods -

-

{error}

-

Please try again later.

-
-
-
-
- ); - } - - // Main payment methods page - return ( - } - title="Payment Methods" - description="Manage your saved payment methods and billing information" - > - - {/* Main Content */} -
- {/* Payment Methods Card */} -
-
-
-
-
- -
-

Manage Payment Methods

-

- Access your secure payment methods dashboard to add, edit, or remove payment - options. -

- - - -

Opens in a new tab for security

-
-
-
-
- - {/* Security Info Sidebar */} -
-
-
-
- -
-
-

Secure & Encrypted

-

- All payment information is securely encrypted and protected with industry-standard - security. -

-
-
-
- -
-

Supported Payment Methods

-
    -
  • • Credit Cards (Visa, MasterCard, American Express)
  • -
  • • Debit Cards
  • -
-
-
-
-
- ); + return ; } diff --git a/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx b/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx index 34b864f3..6029868b 100644 --- a/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx @@ -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(null); - const [loading, setLoading] = useState(true); - const router = useRouter(); - const searchParams = useSearchParams(); - const planSku = searchParams.get("plan"); - - const [mode, setMode] = useState(null); - const [installPlan, setInstallPlan] = useState(null); - const [addons, setAddons] = useState([]); - const [installations, setInstallations] = useState([]); - const [selectedAddonSkus, setSelectedAddonSkus] = useState([]); - 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("/catalog/internet/plans"), - authenticatedApi.get("/catalog/internet/addons"), - authenticatedApi.get("/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 ( - } - title="Configure Internet Service" - description="Set up your internet service options" - > - - - ); - } - - if (!plan) { - return ( - } - title="Configure Internet Service" - description="Set up your internet service options" - > -
-

Plan not found

- - - Back to Internet Plans - -
-
- ); - } - - return ( - } title="" description=""> -
- {/* Header Section */} -
- - - Back to Internet Plans - - -

Configure {plan.name}

- -
-
- {plan.internetPlanTier || "Plan"} -
- - {plan.name} - {plan.monthlyPrice && ( - <> - - - ¥{plan.monthlyPrice.toLocaleString()}/month - - - )} -
-
- - {/* Progress Steps */} - - -
- {/* Step 1: Service Configuration */} - {currentStep === 1 && ( - -
-
-
- 1 -
-

Service Configuration

-
-

Review your plan details and configuration

-
- - {/* Important Message for Platinum */} - {plan?.internetPlanTier === "Platinum" && ( -
-
-
- - - -
-
-
- IMPORTANT - For PLATINUM subscribers -
-

- Additional fees are incurred for the PLATINUM service. Please refer to the - information from our tech team for details. -

-

- * Will appear on the invoice as "Platinum Base Plan". Device - subscriptions will be added later. -

-
-
-
- )} - - {/* Access Mode Selection - Only for Silver */} - {plan?.internetPlanTier === "Silver" ? ( -
-

- Select Your Router & ISP Configuration: -

-
- - - -
- - {/* Continue Button */} -
- { - if (!mode) { - return; - } - transitionToStep(2); - }} - disabled={!mode} - className="flex items-center" - > - Continue to Installation - - -
-
- ) : ( -
-
-
- - - - - Access Mode: IPoE-HGW (Pre-configured for {plan?.internetPlanTier} plan) - -
-
-
- { - setMode("IPoE-BYOR"); - transitionToStep(2); - }} - className="flex items-center" - > - Continue to Installation - - -
-
- )} -
- )} - - {/* Step 2: Installation */} - {currentStep === 2 && mode && ( - -
- -
- - inst.catalogMetadata.installationTerm === installPlan - ) || null - } - onInstallationSelect={installation => { - setInstallPlan(installation.catalogMetadata.installationTerm); - }} - showSkus={false} - /> - -
-
-
- - - -
-
-

Weekend Installation

-

- Weekend installation is available with an additional ¥3,000 charge. Our team - will contact you to schedule the most convenient time. -

-
-
-
- - {/* Continue Button */} -
- transitionToStep(1)} - variant="outline" - className="flex items-center" - > - - Back to Service Details - - { - if (!installPlan) { - return; - } - transitionToStep(3); - }} - disabled={!installPlan} - className="flex items-center" - > - Continue to Add-ons - - -
-
- )} - - {/* Step 3: Add-ons */} - {currentStep === 3 && installPlan && ( - -
- -
- - - {/* Navigation Buttons */} -
- transitionToStep(2)} - variant="outline" - className="flex items-center" - > - - Back to Installation - - transitionToStep(4)} className="flex items-center"> - Review Order - - -
-
- )} - - {/* Step 4: Review Order */} - {currentStep === 4 && plan && ( - -
- -
- - {/* Receipt-Style Order Summary */} -
- {/* Receipt Header */} -
-

Order Summary

-

Review your configuration

-
- - {/* Plan Details */} -
-
-
-

{plan.name}

-

Internet Service

-
-
-

- ¥{plan.monthlyPrice?.toLocaleString()} -

-

per month

-
-
-
- - {/* Configuration Details */} -
-

Configuration

-
-
- Access Mode: - {mode || "Not selected"} -
-
-
- - {/* Add-ons */} - {selectedAddonSkus.length > 0 && ( -
-

Add-ons

-
- {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 ( -
- {addon?.name || addonSku} - - ¥{amount.toLocaleString()} - /{cadence} - -
- ); - })} -
-
- )} - - {/* 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 ? ( -
-

Installation

-
- {installation.name} - - ¥{amount.toLocaleString()} - - /{monthlyAmount ? "mo" : "once"} - - -
-
- ) : null; - })()} - - {/* Totals */} -
-
-
- Monthly Total - ¥{monthlyTotal.toLocaleString()} -
- {oneTimeTotal > 0 && ( -
- One-time Total - - ¥{oneTimeTotal.toLocaleString()} - -
- )} -
-
- - {/* Receipt Footer */} -
-

High-speed internet service

-
-
- - {/* Action Buttons */} -
- transitionToStep(3)} - variant="outline" - size="lg" - className="px-8 py-4 text-lg" - > - - Back to Add-ons - - - Proceed to Checkout - - -
-
- )} -
-
-
- ); -} +import InternetConfigureContainer from "@/features/catalog/views/InternetConfigure"; export default function InternetConfigurePage() { - return ( - }> - - - ); + return ; } diff --git a/apps/portal/src/app/(portal)/catalog/internet/page.tsx b/apps/portal/src/app/(portal)/catalog/internet/page.tsx index bd7c9dce..c2181889 100644 --- a/apps/portal/src/app/(portal)/catalog/internet/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/internet/page.tsx @@ -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([]); - const [installations, setInstallations] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [eligibility, setEligibility] = useState(""); - - useEffect(() => { - let mounted = true; - void (async () => { - try { - setLoading(true); - setError(null); - - const [plans, installations] = await Promise.all([ - authenticatedApi.get("/catalog/internet/plans"), - authenticatedApi.get("/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 ; - } - if (lower.includes("apartment")) { - return ; - } - return ; - }; - - 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 ( - } - > - - - ); - } - - if (error) { - return ( - } - > -
-
Failed to load plans
-
{error}
- - - Back to Services - -
-
- ); - } - - return ( - } - > -
- {/* Navigation */} -
- - - Back to Services - -
- -
-

Choose Your Internet Plan

- - {eligibility && ( -
-
- {getEligibilityIcon(eligibility)} - Available for: {eligibility} -
-

- Plans shown are tailored to your house type and local infrastructure -

-
- )} -
- - {/* Plans Grid */} - {plans.length > 0 ? ( - <> -
- {plans.map(plan => ( - - ))} -
- - {/* Important Notes */} -
-

Important Notes:

-
    -
  • - - Theoretical internet speed is the same for all three packages -
  • -
  • - - One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments -
  • -
  • - - Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month - + ¥1,000-3,000 one-time) -
  • -
  • - - In-home technical assistance available (¥15,000 onsite visiting fee) -
  • -
-
- - ) : ( -
- -

No Plans Available

-

- We couldn't find any internet plans available for your location at this time. -

- - - Back to Services - -
- )} -
-
- ); -} - -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 ( - -
- {/* Header */} -
-
- - {tier || "Plan"} - - {isGold && ( - - Recommended - - )} -
- {plan.monthlyPrice && ( -
-
- - {plan.monthlyPrice.toLocaleString()} - - per month - -
-
- )} -
- - {/* Plan Details */} -

{plan.name}

-

- {plan.catalogMetadata.tierDescription || plan.description} -

- - {/* Your Plan Includes */} -
-

Your Plan Includes:

-
    - {plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? ( - plan.catalogMetadata.features.map((feature, index) => ( -
  • - - {feature} -
  • - )) - ) : ( - <> -
  • - 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 -
  • -
  • - - Monthly: ¥{plan.monthlyPrice?.toLocaleString()} - {minInstallationPrice !== null && ( - - (+ installation from ¥ - {minInstallationPrice.toLocaleString()}) - - )} -
  • - - )} -
-
- - {/* CTA Button */} - - Configure Plan - - -
-
- ); + return ; } diff --git a/apps/portal/src/app/(portal)/catalog/page.tsx b/apps/portal/src/app/(portal)/catalog/page.tsx index 2c23fdc9..970a2c73 100644 --- a/apps/portal/src/app/(portal)/catalog/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/page.tsx @@ -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 ( - } title="" description=""> -
- {/* Hero Section */} -
-
- - Services Catalog -
-

- Choose Your Perfect -
- - Connectivity Solution - -

-

- 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. -

-
- - {/* Service Cards */} -
- {/* Internet Service */} - } - features={[ - "Up to 10Gbps speeds", - "Fiber optic technology", - "Multiple access modes", - "Professional installation", - ]} - href="/catalog/internet" - color="blue" - /> - - {/* SIM/eSIM Service */} - } - features={[ - "Physical SIM & eSIM", - "Data + SMS/Voice plans", - "Family discounts", - "Multiple data options", - ]} - href="/catalog/sim" - color="green" - /> - - {/* VPN Service */} - } - features={[ - "Secure encryption", - "Multiple locations", - "Business & personal", - "24/7 connectivity", - ]} - href="/catalog/vpn" - color="purple" - /> -
- - {/* Additional Info Section */} -
-
-

Why Choose Our Services?

-

- We provide personalized service recommendations based on your location and needs, - ensuring you get the best connectivity solution for your situation. -

-
- -
- } - title="Location-Based Plans" - description="Internet plans tailored to your house type and available infrastructure" - /> - } - title="Seamless Integration" - description="All services work together and can be managed from your single account" - /> -
-
-
-
- ); -} - -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 ( - -
- {/* Icon and Title */} -
-
-
{icon}
-
-
-

{title}

-
-
- - {/* Description */} -

{description}

- - {/* Features */} -
    - {features.map((feature, index) => ( -
  • -
    - {feature} -
  • - ))} -
- - {/* CTA Button */} -
- - Explore Plans - - -
-
- - {/* Decorative Background */} -
- - ); -} - -function FeatureCard({ - icon, - title, - description, -}: { - icon: React.ReactNode; - title: string; - description: string; -}) { - return ( - -
-
{icon}
-
-

{title}

-

{description}

-
- ); + return ; } diff --git a/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx b/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx index a94a907d..d36bdbbb 100644 --- a/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx @@ -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(null); - const [activationFees, setActivationFees] = useState([]); - const [addons, setAddons] = useState([]); - const [loading, setLoading] = useState(true); - - // Configuration state - const [simType, setSimType] = useState<"eSIM" | "Physical SIM">("Physical SIM"); - const [eid, setEid] = useState(""); - const [selectedAddons, setSelectedAddons] = useState([]); - - // 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>({}); - - // 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("/catalog/sim/plans"), - authenticatedApi.get("/catalog/sim/activation-fees"), - authenticatedApi.get("/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 = {}; - - // 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 ( - } - > - - - ); - } - - if (!plan) { - return ( - } - > -
- -

Plan Not Found

-

The selected plan could not be found

- - ← Return to SIM Plans - -
-
- ); - } - - 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 ( - } - > -
- {/* Navigation */} -
- - - Back to SIM Plans - -
- {/* Selected Plan Summary */} - -
-
-
- -

{plan.name}

- {plan.simHasFamilyDiscount && ( - - - Family Discount - - )} -
-
- - Data: {plan.simDataSize} - - - Type:{" "} - {(plan.simPlanType || "DataSmsVoice") === "DataSmsVoice" - ? "Data + Voice" - : (plan.simPlanType || "DataSmsVoice") === "DataOnly" - ? "Data Only" - : "Voice Only"} - -
-
-
-
- ¥{plan.monthlyPrice?.toLocaleString()}/mo -
- {plan.simHasFamilyDiscount && ( -
Discounted Price
- )} -
-
-
- - {/* Progress Steps */} - - - {/* Platinum Warning */} - {plan.name.toLowerCase().includes("platinum") && ( -
-
- -
-
PLATINUM Plan Notice
-

- Additional device subscription fees may apply. Contact support for details. -

-
-
-
- )} - -
- {/* Step 1: SIM Type Selection */} - {currentStep === 1 && ( - - - setSimType(type)} - eid={eid} - onEidChange={setEid} - errors={errors} - /> - - {/* Continue Button */} -
- { - // 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 - - -
-
- )} - - {/* Step 2: Activation Date Selection */} - {currentStep === 2 && ( - -
- -
- - - - {/* Navigation Buttons */} -
- transitionToStep(1)} - variant="outline" - className="flex items-center" - > - - Back to SIM Type - - { - // 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 - - -
-
- )} - - {/* Step 3: Add-ons */} - {currentStep === 3 && ( - -
- -
- - {addons.length > 0 && (plan.simPlanType || "DataSmsVoice") !== "DataOnly" ? ( - ({ - ...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} - /> - ) : ( -
-

- {(plan.simPlanType || "DataSmsVoice") === "DataOnly" - ? "No add-ons are available for data-only plans." - : "No add-ons are available for this plan."} -

-
- )} - - {/* Navigation Buttons */} -
- transitionToStep(2)} - variant="outline" - className="flex items-center" - > - - Back to Activation - - transitionToStep(4)} className="flex items-center"> - Continue to Number Porting - - -
-
- )} - - {/* Step 4: Number Porting (MNP) */} - {currentStep === 4 && ( - -
- -
- - - - {/* Navigation Buttons */} -
- transitionToStep(3)} - variant="outline" - className="flex items-center" - > - - Back to Add-ons - - { - // 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 - - -
-
- )} -
- - {/* Step 5: Review Order */} - {currentStep === 5 && ( - -
- -
- - {/* Receipt-Style Order Summary */} -
- {/* Receipt Header */} -
-

Order Summary

-

Review your configuration

-
- - {/* Plan Details */} -
-
-
-

{plan.name}

-

{plan.simDataSize}

-
-
-

- ¥{plan.monthlyPrice?.toLocaleString()} -

-

per month

-
-
-
- - {/* Configuration Details */} -
-

Configuration

-
-
- SIM Type: - {simType || "Not selected"} -
- {simType === "eSIM" && eid && ( -
- EID: - - {eid.substring(0, 12)}... - -
- )} -
- Activation: - - {activationType === "Scheduled" && scheduledActivationDate - ? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}` - : activationType || "Not selected"} - -
- {wantsMnp && ( -
- Number Porting: - Requested -
- )} -
-
- - {/* Add-ons */} - {selectedAddons.length > 0 && ( -
-

Add-ons

-
- {selectedAddons.map(addonSku => { - const addon = addons.find(a => a.sku === addonSku); - return ( -
- {addon?.name || addonSku} - - ¥{addon?.price?.toLocaleString() || 0} - - /{addon?.billingCycle === "Monthly" ? "mo" : "once"} - - -
- ); - })} -
-
- )} - - {/* Activation Fees */} - {activationFees.length > 0 && activationFees.some(fee => fee.price > 0) && ( -
-

One-time Fees

-
- {activationFees.map((fee, index) => ( -
- {fee.name} - ¥{fee.price?.toLocaleString() || 0} -
- ))} -
-
- )} - - {/* Totals */} -
-
-
- Monthly Total - ¥{monthlyTotal.toLocaleString()} -
- {oneTimeTotal > 0 && ( -
- One-time Total - - ¥{oneTimeTotal.toLocaleString()} - -
- )} -
-
- - {/* Receipt Footer */} -
-

- {(plan.simPlanType || "DataSmsVoice") === "DataOnly" && "Data-only service (no voice features)"} -

-
-
- - {/* Action Buttons */} -
- transitionToStep(4)} - variant="outline" - size="lg" - className="px-8 py-4 text-lg" - > - - Back to Number Porting - - - Proceed to Checkout - - -
-
- )} -
-
- ); -} +import SimConfigureContainer from "@/features/catalog/views/SimConfigure"; export default function SimConfigurePage() { - return ( - }> - - - ); + return ; } diff --git a/apps/portal/src/app/(portal)/catalog/sim/page.tsx b/apps/portal/src/app/(portal)/catalog/sim/page.tsx index cf682ec5..a04d19d3 100644 --- a/apps/portal/src/app/(portal)/catalog/sim/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/sim/page.tsx @@ -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 ( -
-
- {icon} -
-

{title}

-

{description}

-
-
- - {/* Regular Plans */} -
- {regularPlans.map(plan => ( - - ))} -
- - {/* Family Discount Plans */} - {showFamilyDiscount && familyPlans.length > 0 && ( - <> -
- -

Family Discount Options

- - You qualify! - -
-
- {familyPlans.map(plan => ( - - ))} -
- - )} -
- ); -} - -function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) { - return ( - -
-
-
- - {plan.simDataSize} -
- {isFamily && ( -
- - - Family - -
- )} -
-
- -
-
- - - {plan.monthlyPrice?.toLocaleString()} - - /month -
- {isFamily && ( -
Discounted price
- )} -
- -
-

{plan.description}

-
- - - Configure - -
- ); -} - -export default function SimPlansPage() { - const [plans, setPlans] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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("/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 ( - } - > - - - ); - } - - if (error) { - return ( - } - > -
-
Failed to load SIM plans
-
{error}
- - - Back to Services - -
-
- ); - } - - // 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 ( - } - > -
- {/* Navigation */} -
- - - Back to Services - -
- -
-

Choose Your SIM Plan

-

- Wide range of data options and voice plans with both physical SIM and eSIM options. -

-
- {/* Family Discount Banner */} - {hasExistingSim && ( -
-
-
- -
-
-

- 🎉 Family Discount Available! -

-

- You have existing SIM services, so you qualify for family discount pricing on - additional lines. -

-
-
- - Reduced monthly pricing -
-
- - Same great features -
-
- - Easy to manage -
-
-
-
-
- )} - - {/* Tab Navigation */} -
-
- -
-
- - {/* Tab Content */} -
-
- {activeTab === "data-voice" && ( - } - plans={plansByType.DataSmsVoice} - showFamilyDiscount={hasExistingSim} - /> - )} -
- -
- {activeTab === "data-only" && ( - } - plans={plansByType.DataOnly} - showFamilyDiscount={hasExistingSim} - /> - )} -
- -
- {activeTab === "voice-only" && ( - } - plans={plansByType.VoiceOnly} - showFamilyDiscount={hasExistingSim} - /> - )} -
-
- - {/* Features Section */} -
-

- Plan Features & Terms -

-
-
- -
-
3-Month Contract
-
Minimum 3 billing months
-
-
-
- -
-
First Month Free
-
Basic fee waived initially
-
-
-
- -
-
5G Network
-
High-speed coverage
-
-
-
- -
-
eSIM Support
-
Digital activation
-
-
-
- -
-
Family Discounts
-
Multi-line savings
-
-
-
- -
-
Plan Switching
-
Free data plan changes
-
-
-
-
- - {/* Info Section */} -
-
- -
-
Important Terms & Conditions
-
-
-
-
-
-
Contract Period
-

- Minimum 3 full billing months required. First month (sign-up to end of month) is - free and doesn't count toward contract. -

-
-
-
Billing Cycle
-

- Monthly billing from 1st to end of month. Regular billing starts on 1st of - following month after sign-up. -

-
-
-
Cancellation
-

- Can be requested online after 3rd month. Service terminates at end of billing - cycle. -

-
-
-
-
-
Plan Changes
-

- Data plan switching is free and takes effect next month. Voice plan changes - require new SIM and cancellation policies apply. -

-
-
-
Calling/SMS Charges
-

- Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing - cycle. -

-
-
-
SIM Replacement
-

- Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards. -

-
-
-
-
-
-
- ); +export default function SimCatalogPage() { + return ; } diff --git a/apps/portal/src/app/(portal)/catalog/vpn/page.tsx b/apps/portal/src/app/(portal)/catalog/vpn/page.tsx index 8a79e043..1486c847 100644 --- a/apps/portal/src/app/(portal)/catalog/vpn/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/vpn/page.tsx @@ -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([]); - const [activationFees, setActivationFees] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - void (async () => { - try { - setLoading(true); - setError(null); - - const [plans, fees] = await Promise.all([ - authenticatedApi.get("/catalog/vpn/plans"), - authenticatedApi.get("/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 ( - } - > - - - ); - } - - if (error) { - return ( - } - > -
-
Failed to load VPN plans
-
{error}
- - - Back to Services - -
-
- ); - } - - return ( - } - > -
- {/* Navigation */} -
- - - Back to Services - -
- -
-

- SonixNet VPN Rental Router Service -

-

- Fast and secure VPN connection to San Francisco or London for accessing geo-restricted - content. -

-
- {/* Available Plans Section */} - {vpnPlans.length > 0 ? ( -
-

Available Plans

-

(One region per router)

- -
- {vpnPlans.map(plan => { - return ( - -
-

{plan.name}

-
- -
-
- - - {plan.monthlyPrice?.toLocaleString()} - - /month -
-
- - {/* VPN plans don't have features defined in the type structure */} - - - Configure Plan - -
- ); - })} -
- - {activationFees.length > 0 && ( -
-

- A one-time activation fee of 3000 JPY is incurred seprarately for each rental - unit. Tax (10%) not included. -

-
- )} -
- ) : ( -
- -

No VPN Plans Available

-

- We couldn't find any VPN plans available at this time. -

- - - Back to Services - -
- )} - - {/* Service Description Section */} -
-

How It Works

-
-

- 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. -

-

- 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. -

-

- Then you can connect your network media players to the VPN Wi-Fi network, to connect - to the VPN server. -

-

- For daily Internet usage that does not require a VPN, we recommend connecting to your - regular home Wi-Fi. -

-
-
- - {/* Disclaimer Section */} -
-

Important Disclaimer

-

- *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. -

-
-
-
- ); +export default function VpnCatalogPage() { + return ; } diff --git a/apps/portal/src/app/(portal)/checkout/page.tsx b/apps/portal/src/app/(portal)/checkout/page.tsx index b4fc67b1..dab56689 100644 --- a/apps/portal/src/app/(portal)/checkout/page.tsx +++ b/apps/portal/src/app/(portal)/checkout/page.tsx @@ -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
(null); - - const [checkoutState, setCheckoutState] = useState({ - 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 = {}; - 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("/catalog/internet/plans"), - authenticatedApi.get("/catalog/internet/addons"), - authenticatedApi.get("/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("/catalog/sim/plans"), - authenticatedApi.get("/catalog/sim/activation-fees"), - authenticatedApi.get("/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 = {}; - - // 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 ( - } - > -
Loading order submission...
-
- ); - } - - if (checkoutState.error) { - return ( - } - > -
-

{checkoutState.error}

- -
-
- ); - } - - return ( - } - > -
- - {/* Confirm Details - single card with Address + Payment */} -
-
- -

Confirm Details

-
-
- {/* Sub-card: Installation Address */} - - - - - {/* Sub-card: Billing & Payment */} - } - right={ - paymentMethods && paymentMethods.paymentMethods.length > 0 ? ( - - ) : undefined - } - > - {paymentMethodsLoading ? ( -
-
- Checking payment methods... -
- ) : paymentMethodsError ? ( -
-
- -
-

- Unable to verify payment methods -

-

- If you just added a payment method, try refreshing. -

-
- - -
-
-
-
- ) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? ( -

- Payment will be processed using your card on file after approval. -

- ) : ( -
-
- -
-

No payment method on file

-

- Add a payment method to submit your order. -

-
- - -
-
-
-
- )} -
-
-
- - {/* Review & Submit - prominent card with guidance */} -
-
- -
-

Review & Submit

-

- You’re almost done. Confirm your details above, then submit your order. We’ll review and - notify you when everything is ready. -

-
-

What to expect

-
-

• Our team reviews your order and schedules setup if needed

-

• We may contact you to confirm details or availability

-

• We only charge your card after the order is approved

-

• You’ll receive confirmation and next steps by email

-
-
- - {/* Totals Summary */} -
-
- Estimated Total -
-
- ¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo -
- {checkoutState.totals.oneTimeTotal > 0 && ( -
- + ¥{checkoutState.totals.oneTimeTotal.toLocaleString()} one-time -
- )} -
-
-
-
- -
- - - -
-
-
- ); -} +import CheckoutContainer from "@/features/checkout/views/CheckoutContainer"; export default function CheckoutPage() { - return ( - Loading checkout...
}> - - - ); + return ; } diff --git a/apps/portal/src/app/(portal)/orders/[id]/page.tsx b/apps/portal/src/app/(portal)/orders/[id]/page.tsx index 57a0ddd0..c9f0394c 100644 --- a/apps/portal/src/app/(portal)/orders/[id]/page.tsx +++ b/apps/portal/src/app/(portal)/orders/[id]/page.tsx @@ -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 ; - case "SIM": - return ; - case "VPN": - return ; - default: - return ; - } -}; - -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(null); - const [error, setError] = useState(null); - const isNewOrder = searchParams.get("status") === "success"; - - useEffect(() => { - let mounted = true; - const fetchStatus = async () => { - try { - const res = await authenticatedApi.get(`/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 ( - } - title={data ? `${data.orderType} Service Order` : "Order Details"} - description={ - data ? `Order #${data.orderNumber || data.id.slice(-8)}` : "Loading order details..." - } - > - {error &&
{error}
} - - {/* Success Banner for New Orders */} - {isNewOrder && ( -
-
- -
-

- Order Submitted Successfully! -

-

- Your order has been created and submitted for processing. We will notify you as soon - as it's approved and ready for activation. -

-
-

- What happens next: -

-
    -
  • Our team will review your order (within 1 business day)
  • -
  • You'll receive an email confirmation once approved
  • -
  • We will schedule activation based on your preferences
  • -
  • This page will update automatically as your order progresses
  • -
-
-
-
-
- )} - - {/* 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 ( - Status} - > -
-
{statusInfo.description}
- -
- - {/* Highlighted Next Steps Section */} - {statusInfo.nextAction && ( -
-
-
-

Next Steps

-
-

{statusInfo.nextAction}

-
- )} - - {statusInfo.timeline && ( -
-

- Timeline: {statusInfo.timeline} -

-
- )} -
- ); - })()} - - {/* Combined Service Overview and Products */} - {data && ( -
- {/* Service Header */} -
-
- {getServiceTypeIcon(data.orderType)} -
-
-

- {data.orderType} Service -

-

- Order #{data.orderNumber || data.id.slice(-8)} • Placed{" "} - {new Date(data.createdDate).toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - year: "numeric", - })} -

-
- - {data.items && - data.items.length > 0 && - (() => { - const totals = calculateDetailedTotals(data.items); - - return ( -
-
- {totals.monthlyTotal > 0 && ( -
-

- ¥{totals.monthlyTotal.toLocaleString()} -

-

per month

-
- )} - - {totals.oneTimeTotal > 0 && ( -
-

- ¥{totals.oneTimeTotal.toLocaleString()} -

-

one-time

-
- )} - - {/* Fallback to TotalAmount if no items or calculation fails */} - {totals.monthlyTotal === 0 && - totals.oneTimeTotal === 0 && - data.totalAmount && ( -
-

- ¥{data.totalAmount.toLocaleString()} -

-

total amount

-
- )} -
-
- ); - })()} -
- - {/* Services & Products Section */} - {data?.items && data.items.length > 0 && ( -
-

Your Services & Products

-
- {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: , - 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: , - 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: , - 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: , - 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 ( -
-
-
-
- {typeInfo.icon} -
- -
-
-

- {item.product.name} -

- - {typeInfo.label} - -
- -
- {item.product.billingCycle} - {item.quantity > 1 && Qty: {item.quantity}} - {item.product.itemClass && ( - - {item.product.itemClass} - - )} -
-
-
- -
- {item.totalPrice && ( -
- ¥{item.totalPrice.toLocaleString()} -
- )} -
- {item.product.billingCycle === "Monthly" ? "/month" : "one-time"} -
-
-
-
- ); - })} - - {/* Additional fees warning */} -
-
- -
-

- Additional fees may apply -

-

- Weekend installation (+¥3,000), express setup, or special configuration - charges may be added. We will contact you before applying any additional - fees. -

-
-
-
-
-
- )} -
- )} - - {/* Support Contact */} - -
-
-

- Questions about your order? Contact our support team. -

-
- -
-
-
- ); +export default function OrderDetailPage() { + return ; } diff --git a/apps/portal/src/app/(portal)/orders/page.tsx b/apps/portal/src/app/(portal)/orders/page.tsx index efba0649..dfda86db 100644 --- a/apps/portal/src/app/(portal)/orders/page.tsx +++ b/apps/portal/src/app/(portal)/orders/page.tsx @@ -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 ( -
-
- -
-

- Order Submitted Successfully! -

-

- Your order has been created and is now being processed. You can track its progress - below. -

-
-
-
- ); -} +import OrdersListContainer from "@/features/orders/views/OrdersList"; export default function OrdersPage() { - const router = useRouter(); - const [orders, setOrders] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchOrders = async () => { - try { - const data = await authenticatedApi.get("/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: , label: "Internet Service" }; - case "SIM": - return { icon: , label: "Mobile Service" }; - case "VPN": - return { icon: , label: "VPN Service" }; - default: - return { icon: , 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 ( - } - title="My Orders" - description="View and track all your orders" - > - {/* Success Banner (Suspense for useSearchParams) */} - - - - - {error && ( -
-

{error}

-
- )} - - {loading ? ( -
-
-

Loading your orders...

-
- ) : orders.length === 0 ? ( -
- -

No orders yet

-

You haven't placed any orders yet.

- -
- ) : ( -
- {orders.map(order => { - const statusInfo = getStatusInfo(order.status, order.activationStatus); - const serviceType = getServiceTypeDisplay(order.orderType); - const serviceSummary = getServiceSummary(order); - - return ( -
router.push(`/orders/${order.id}`)} - > - {/* Header */} -
-
-
{serviceType.icon}
-
-

- {serviceType.label} -

-

- Order #{order.orderNumber || order.id.slice(-8)} •{" "} - {new Date(order.createdDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} -

-
-
- -
- -
-
- - {/* Service Details */} -
-
-
-

{serviceSummary}

-

{statusInfo.description}

- {statusInfo.nextAction && ( -

- {statusInfo.nextAction} -

- )} -
- - {(() => { - const totals = calculateOrderTotals(order); - if (totals.monthlyTotal <= 0 && totals.oneTimeTotal <= 0) return null; - - return ( -
-
-

- ¥{totals.monthlyTotal.toLocaleString()} -

-

per month

- - {totals.oneTimeTotal > 0 && ( - <> -

- ¥{totals.oneTimeTotal.toLocaleString()} -

-

one-time

- - )} -
- - {/* Fee Disclaimer */} -
-

* Additional fees may apply

-

(e.g., weekend installation)

-
-
- ); - })()} -
-
- - {/* Action Indicator */} -
- Click to view details - - - -
-
- ); - })} -
- )} -
- ); + return ; } diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx b/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx index 66106cab..4073bbde 100644 --- a/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx +++ b/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx @@ -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 ; - case "Suspended": - return ; - case "Terminated": - return ; - case "Cancelled": - return ; - case "Pending": - return ; - default: - return ; - } - }; - - 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 ; - case "Overdue": - return ; - case "Unpaid": - return ; - default: - return ; - } - }; - - 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 ( -
-
-
-

Loading subscription...

-
-
- ); - } - - if (error || !subscription) { - return ( -
-
-
-
- -
-
-

Error loading subscription

-
- {error instanceof Error ? error.message : "Subscription not found"} -
-
- - ← Back to subscriptions - -
-
-
-
-
- ); - } - - return ( - <> -
-
- {/* Header */} -
-
-
- - - -
- -
-

{subscription.productName}

-

Service ID: {subscription.serviceId}

-
-
-
-
-
- - {/* Subscription Summary Card */} -
-
-
-
- {getStatusIcon(subscription.status)} -
-

Subscription Details

-

Service subscription information

-
-
- - {subscription.status} - -
-
- -
-
-
-

- Billing Amount -

-

- {formatCurrency(subscription.amount)} -

-

{formatBillingLabel(subscription.cycle)}

-
-
-

- Next Due Date -

-

{formatDate(subscription.nextDue)}

-
- - Due date -
-
-
-

- Registration Date -

-

- {formatDate(subscription.registrationDate)} -

- Service created -
-
-
-
- - {/* Navigation tabs for SIM services - More visible and mobile-friendly */} - {subscription.productName.toLowerCase().includes("sim") && ( -
-
-
-
-

Service Management

-

- Switch between billing and SIM management views -

-
-
- - - SIM Management - - - - Billing - -
-
-
-
- )} - - {/* SIM Management Section - Only show when in SIM context and for SIM services */} - {showSimManagement && subscription.productName.toLowerCase().includes("sim") && ( - - )} - - {/* Related Invoices (hidden when viewing SIM management directly) */} - {showInvoices && ( -
-
-
- -

Related Invoices

-
-

- Invoices containing charges for this subscription -

-
- - {invoicesLoading ? ( -
-
-

Loading invoices...

-
- ) : invoicesError ? ( -
- -

Error loading invoices

-

- {invoicesError instanceof Error - ? invoicesError.message - : "Failed to load related invoices"} -

-
- ) : invoices.length === 0 ? ( -
- -

No invoices found

-

- No invoices have been generated for this subscription yet. -

-
- ) : ( - <> -
-
- {invoices.map(invoice => ( -
-
-
-
- {getInvoiceStatusIcon(invoice.status)} -
-
-

- Invoice {invoice.number} -

-

- Issued{" "} - {invoice.issuedAt && - format(new Date(invoice.issuedAt), "MMM d, yyyy")} -

-
-
-
- - {invoice.status} - - - {formatCurrency(invoice.total)} - -
-
-
-
- - Due:{" "} - {invoice.dueDate - ? format(new Date(invoice.dueDate), "MMM d, yyyy") - : "N/A"} - -
- -
-
- ))} -
-
- - {/* Pagination */} - {pagination && pagination.totalPages > 1 && ( -
-
- - -
-
-
-

- Showing{" "} - - {(currentPage - 1) * itemsPerPage + 1} - {" "} - to{" "} - - {Math.min(currentPage * itemsPerPage, pagination.totalItems)} - {" "} - of {pagination.totalItems} results -

-
-
- -
-
-
- )} - - )} -
- )} -
-
- - ); + return ; } diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx index 28832d05..c103af27 100644 --- a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx +++ b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx @@ -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 ( -
-
{title}
-
{children}
-
- ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} +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(1); - const [loading, setLoading] = useState(false); - const [details, setDetails] = useState(null); - const [error, setError] = useState(null); - const [message, setMessage] = useState(null); - const [acceptTerms, setAcceptTerms] = useState(false); - const [confirmMonthEnd, setConfirmMonthEnd] = useState(false); - const [cancelMonth, setCancelMonth] = useState(""); // YYYYMM - const [email, setEmail] = useState(""); - const [email2, setEmail2] = useState(""); - const [notes, setNotes] = useState(""); - const [registeredEmail, setRegisteredEmail] = useState(null); - - useEffect(() => { - const fetchDetails = async () => { - try { - const d = await authenticatedApi.get( - `/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 ( -
-
- - ← Back to SIM Management - -
Step {step} of 3
-
- - {error && ( -
{error}
- )} - {message && ( -
- {message} -
- )} - -
-

Cancel SIM

-

- Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will - terminate your service immediately. -

- - {message && ( -
- {message} -
- )} - - {step === 2 && ( -
-
- - 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 - - - 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. - - - 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. - - - 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. - -
-
- - -
- - -

- Cancellation takes effect at the start of the selected month. -

-
-
-
- setAcceptTerms(e.target.checked)} - /> - -
-
- setConfirmMonthEnd(e.target.checked)} - disabled={!cancelMonth} - /> - -
-
- - -
-
- )} - - {step === 3 && ( -
- - 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{" "} - - info@asolutions.co.jp - - . - - {registeredEmail && ( -
- Your registered email address is:{" "} - {registeredEmail} -
- )} -
- You will receive a cancellation confirmation email. If you would like to receive - this email on a different address, please enter the address below. -
-
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - /> -
-
- - setEmail2(e.target.value)} - placeholder="you@example.com" - /> -
-
- -